Parcourir la source

Add: Supplier module and bug fix

iaun il y a 3 mois
Parent
commit
5b268a804e
41 fichiers modifiés avec 3557 ajouts et 189 suppressions
  1. 202 0
      .kiro/specs/settlement-status-display/design.md
  2. 77 0
      .kiro/specs/settlement-status-display/requirements.md
  3. 82 0
      .kiro/specs/settlement-status-display/tasks.md
  4. 447 0
      .kiro/specs/supplier-management/design.md
  5. 131 0
      .kiro/specs/supplier-management/requirements.md
  6. 184 0
      .kiro/specs/supplier-management/tasks.md
  7. 267 0
      .kiro/specs/ui-enhancements/design.md
  8. 84 0
      .kiro/specs/ui-enhancements/requirements.md
  9. 90 0
      .kiro/specs/ui-enhancements/tasks.md
  10. 2 1
      backend/app/models/__init__.py
  11. 9 2
      backend/app/models/item.py
  12. 36 0
      backend/app/models/supplier.py
  13. 5 0
      backend/app/models/work_record.py
  14. 2 0
      backend/app/routes/__init__.py
  15. 17 7
      backend/app/routes/item.py
  16. 5 3
      backend/app/routes/person.py
  17. 201 0
      backend/app/routes/supplier.py
  18. 143 4
      backend/app/routes/work_record.py
  19. 141 19
      backend/app/services/export_service.py
  20. 53 11
      backend/app/services/item_service.py
  21. 27 7
      backend/app/services/person_service.py
  22. 129 0
      backend/app/services/supplier_service.py
  23. 175 20
      backend/app/services/work_record_service.py
  24. 1 0
      backend/migrations/__init__.py
  25. 290 0
      backend/migrations/add_supplier_and_settlement.py
  26. 34 11
      backend/tests/test_export.py
  27. 2 1
      backend/tests/test_item.py
  28. 2 1
      backend/tests/test_person.py
  29. 2 0
      frontend/src/App.jsx
  30. 0 6
      frontend/src/components/AdminList.jsx
  31. 180 61
      frontend/src/components/Dashboard.jsx
  32. 28 3
      frontend/src/components/ItemForm.jsx
  33. 20 7
      frontend/src/components/ItemList.jsx
  34. 7 1
      frontend/src/components/Layout.jsx
  35. 0 6
      frontend/src/components/PersonList.jsx
  36. 99 0
      frontend/src/components/SupplierForm.jsx
  37. 167 0
      frontend/src/components/SupplierList.jsx
  38. 4 1
      frontend/src/components/WorkRecordForm.jsx
  39. 191 16
      frontend/src/components/WorkRecordList.jsx
  40. 9 0
      frontend/src/index.css
  41. 12 1
      frontend/src/services/api.js

+ 202 - 0
.kiro/specs/settlement-status-display/design.md

@@ -0,0 +1,202 @@
+# Design Document: Settlement Status Display
+
+## Overview
+
+本设计扩展仪表盘和导出报表功能,在现有界面和报表中增加结算状态的显示。主要涉及:
+1. 后端API扩展 - 在月度和年度统计API中增加结算状态相关字段
+2. 前端仪表盘更新 - 显示结算收入统计和状态标识
+3. 导出服务扩展 - 在Excel报表中增加结算状态列
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                      Frontend (React)                        │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │                   Dashboard.jsx                      │    │
+│  │  - 月度报告: 已结算/未结算收入统计卡片              │    │
+│  │  - 人员按供应商明细: 结算状态列 + 未结算行高亮      │    │
+│  │  - 年度汇总: 已结算/未结算收入列 (移至底部)         │    │
+│  └─────────────────────────────────────────────────────┘    │
+└─────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────┐
+│                      Backend (Flask)                         │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │              work_record_service.py                  │    │
+│  │  - get_monthly_summary(): 增加结算统计字段          │    │
+│  │  - get_yearly_summary(): 增加结算统计字段           │    │
+│  └─────────────────────────────────────────────────────┘    │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │               export_service.py                      │    │
+│  │  - 月度报表: 明细+汇总增加结算状态列                │    │
+│  │  - 年度报表: 明细+汇总增加结算状态列                │    │
+│  └─────────────────────────────────────────────────────┘    │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Components and Interfaces
+
+### Backend API Changes
+
+#### 1. Monthly Summary API Response Extension
+
+`GET /api/work-records/monthly-summary`
+
+扩展返回字段:
+```python
+{
+    "year": 2024,
+    "month": 12,
+    "total_records": 100,
+    "total_earnings": 5000.00,
+    "settled_earnings": 3000.00,      # 新增
+    "unsettled_earnings": 2000.00,    # 新增
+    "top_performers": [...],
+    "item_breakdown": [...],
+    "supplier_breakdown": [
+        {
+            "person_id": 1,
+            "person_name": "张三",
+            "supplier_name": "供应商A",
+            "earnings": 1000.00,
+            "is_settled": true          # 新增
+        }
+    ]
+}
+```
+
+#### 2. Yearly Summary API Response Extension
+
+`GET /api/work-records/yearly-summary`
+
+扩展返回字段:
+```python
+{
+    "year": 2024,
+    "persons": [
+        {
+            "person_id": 1,
+            "person_name": "张三",
+            "monthly_earnings": [100, 200, ...],
+            "yearly_total": 1500.00,
+            "settled_total": 1000.00,     # 新增
+            "unsettled_total": 500.00     # 新增
+        }
+    ],
+    "monthly_totals": [...],
+    "grand_total": 10000.00,
+    "settled_grand_total": 7000.00,       # 新增
+    "unsettled_grand_total": 3000.00      # 新增
+}
+```
+
+### Frontend Component Changes
+
+#### Dashboard.jsx Updates
+
+1. **月度报告统计卡片区域**
+   - 新增"已结算收入"统计卡片 (绿色图标)
+   - 新增"未结算收入"统计卡片 (橙色图标)
+
+2. **人员按供应商收入明细表格**
+   - 新增"结算状态"列
+   - 未结算行使用浅橙色背景 (#fff7e6)
+
+3. **年度汇总表格**
+   - 新增"已结算"和"未结算"列
+   - 调整位置到仪表盘底部
+
+### Export Service Changes
+
+#### 1. Monthly Report Export
+
+**明细表 (Detail Sheet)**
+- 列顺序: 人员, 日期, 供应商, 物品, 单价, 数量, 总价, 结算状态
+- 结算状态显示: "已结算" / "未结算"
+
+**月度汇总表 (Monthly Summary Sheet)**
+- 列顺序: 人员, 供应商, 总金额, 结算状态
+- 按人员+供应商+结算状态分组汇总
+
+#### 2. Yearly Report Export
+
+**明细表 (Detail Sheet)**
+- 列顺序: 人员, 日期, 供应商, 物品, 单价, 数量, 总价, 结算状态
+
+**年度汇总表 (Yearly Summary Sheet)**
+- 列顺序: 人员, 1月, 2月, ..., 12月, 年度合计, 已结算, 未结算
+
+## Data Models
+
+无需修改数据模型。`work_records` 表已有 `is_settled` 字段:
+
+```python
+class WorkRecord(db.Model):
+    # ... existing fields ...
+    is_settled = db.Column(db.Boolean, nullable=False, default=False)
+```
+
+## 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: Monthly Settlement Sum Consistency
+
+*For any* set of work records in a given month, the sum of settled_earnings and unsettled_earnings returned by the monthly summary API SHALL equal total_earnings.
+
+**Validates: Requirements 1.3**
+
+### Property 2: Yearly Person Settlement Consistency
+
+*For any* person in the yearly summary, settled_total + unsettled_total SHALL equal yearly_total.
+
+**Validates: Requirements 3.2**
+
+### Property 3: Yearly Grand Total Settlement Consistency
+
+*For any* yearly summary response, settled_grand_total + unsettled_grand_total SHALL equal grand_total.
+
+**Validates: Requirements 3.2, 3.3**
+
+### Property 4: Export Settlement Status Text Mapping
+
+*For any* work record exported to Excel, the settlement status column SHALL display "已结算" when is_settled is true and "未结算" when is_settled is false.
+
+**Validates: Requirements 5.3, 6.3**
+
+## Error Handling
+
+1. **API Error Handling**
+   - 如果数据库查询失败,返回500错误和错误消息
+   - 保持现有的错误处理模式
+
+2. **Frontend Error Handling**
+   - 如果API返回缺少新字段,使用默认值0显示
+   - 保持现有的错误提示机制
+
+## Testing Strategy
+
+### Unit Tests
+
+1. **Backend Service Tests**
+   - 测试 `get_monthly_summary()` 返回正确的结算统计
+   - 测试 `get_yearly_summary()` 返回正确的结算统计
+   - 测试导出服务生成正确的结算状态列
+
+2. **Frontend Component Tests**
+   - 测试仪表盘正确显示结算统计卡片
+   - 测试未结算行正确高亮显示
+
+### Property-Based Tests
+
+使用 Hypothesis 库进行属性测试:
+
+1. **Settlement Sum Property Test**
+   - 生成随机工作记录集合
+   - 验证 settled + unsettled = total
+
+2. **Export Accuracy Property Test**
+   - 生成随机工作记录
+   - 验证导出的结算状态与数据库一致

+ 77 - 0
.kiro/specs/settlement-status-display/requirements.md

@@ -0,0 +1,77 @@
+# Requirements Document
+
+## Introduction
+
+本功能扩展现有的仪表盘和导出报表功能,增加结算状态(已结算/未结算)的显示和统计。系统已有 `is_settled` 字段用于标记工作记录的结算状态,本需求将在仪表盘和导出报表中展示这些信息。
+
+**注意**: 数据库中 `work_records` 表已存在 `is_settled` 字段,无需新增数据库迁移。
+
+## Glossary
+
+- **Dashboard**: 仪表盘组件,显示日统计、月度报告和年度汇总
+- **Monthly_Report**: 月度报告,显示当月的工作记录统计
+- **Yearly_Summary**: 年度汇总,显示全年按月的人员收入统计
+- **Export_Service**: 导出服务,生成Excel格式的月度和年度报表
+- **Settlement_Status**: 结算状态,标识工作记录是否已结算(is_settled字段)
+- **Settled_Income**: 已结算收入,is_settled为true的工作记录总金额
+- **Unsettled_Income**: 未结算收入,is_settled为false的工作记录总金额
+
+## Requirements
+
+### Requirement 1: 月度报告增加结算收入统计
+
+**User Story:** As a user, I want to see settled and unsettled income in the monthly report, so that I can track payment status at a glance.
+
+#### Acceptance Criteria
+
+1. WHEN the monthly report is displayed, THE Dashboard SHALL show a "已结算收入" statistic card with the total settled income for the month
+2. WHEN the monthly report is displayed, THE Dashboard SHALL show a "未结算收入" statistic card with the total unsettled income for the month
+3. WHEN the monthly summary API is called, THE Work_Record_Service SHALL return settled_earnings and unsettled_earnings fields
+
+### Requirement 2: 人员按供应商收入明细增加结算状态
+
+**User Story:** As a user, I want to see settlement status in the person-by-supplier breakdown, so that I can identify which earnings are pending payment.
+
+#### Acceptance Criteria
+
+1. WHEN the person-by-supplier breakdown is displayed, THE Dashboard SHALL show a "结算状态" column indicating settled or unsettled status
+2. WHEN a row has unsettled earnings, THE Dashboard SHALL highlight the row with a distinct background color
+3. WHEN the monthly summary API is called, THE Work_Record_Service SHALL return is_settled field for each supplier_breakdown entry
+
+### Requirement 3: 年度汇总增加结算状态
+
+**User Story:** As a user, I want to see settled and unsettled income breakdown in the yearly summary, so that I can track annual payment status by person.
+
+#### Acceptance Criteria
+
+1. WHEN the yearly summary is displayed, THE Dashboard SHALL show both settled and unsettled income for each person
+2. WHEN the yearly summary API is called, THE Work_Record_Service SHALL return settled_total and unsettled_total for each person
+3. WHEN the yearly summary is displayed, THE Dashboard SHALL show grand totals for settled and unsettled income
+
+### Requirement 4: 年度汇总位置调整
+
+**User Story:** As a user, I want the yearly summary to appear at the bottom of the dashboard, so that the most frequently used monthly data is more accessible.
+
+#### Acceptance Criteria
+
+1. WHEN the dashboard is rendered, THE Dashboard SHALL display the yearly summary section after all other sections (at the bottom)
+
+### Requirement 5: 月度报表导出增加结算状态
+
+**User Story:** As a user, I want the exported monthly report to include settlement status, so that I can track payment status in offline reports.
+
+#### Acceptance Criteria
+
+1. WHEN a monthly report is exported, THE Export_Service SHALL include a "结算状态" column in the detail sheet
+2. WHEN a monthly report is exported, THE Export_Service SHALL include a "结算状态" column in the monthly summary sheet
+3. WHEN displaying settlement status, THE Export_Service SHALL show "已结算" for settled records and "未结算" for unsettled records
+
+### Requirement 6: 年度报表导出增加结算状态
+
+**User Story:** As a user, I want the exported yearly report to include settlement status, so that I can track annual payment status in offline reports.
+
+#### Acceptance Criteria
+
+1. WHEN a yearly report is exported, THE Export_Service SHALL include a "结算状态" column in the detail sheet
+2. WHEN a yearly report is exported, THE Export_Service SHALL include settlement status breakdown in the yearly summary sheet
+3. WHEN displaying settlement status, THE Export_Service SHALL show "已结算" for settled records and "未结算" for unsettled records

+ 82 - 0
.kiro/specs/settlement-status-display/tasks.md

@@ -0,0 +1,82 @@
+# Implementation Plan: Settlement Status Display
+
+## Overview
+
+本实现计划将结算状态显示功能分解为后端API扩展、前端仪表盘更新和导出服务扩展三个主要部分。
+
+## Tasks
+
+- [x] 1. 扩展后端月度统计API
+  - [x] 1.1 修改 `work_record_service.py` 的 `get_monthly_summary()` 方法
+    - 计算并返回 `settled_earnings` 和 `unsettled_earnings`
+    - 在 `supplier_breakdown` 中增加 `is_settled` 字段
+    - _Requirements: 1.3, 2.3_
+  - [ ]* 1.2 编写月度统计结算一致性属性测试
+    - **Property 1: Monthly Settlement Sum Consistency**
+    - **Validates: Requirements 1.3**
+
+- [x] 2. 扩展后端年度统计API
+  - [x] 2.1 修改 `work_record_service.py` 的 `get_yearly_summary()` 方法
+    - 为每个人员计算 `settled_total` 和 `unsettled_total`
+    - 计算 `settled_grand_total` 和 `unsettled_grand_total`
+    - _Requirements: 3.2, 3.3_
+  - [ ]* 2.2 编写年度统计结算一致性属性测试
+    - **Property 2: Yearly Person Settlement Consistency**
+    - **Property 3: Yearly Grand Total Settlement Consistency**
+    - **Validates: Requirements 3.2, 3.3**
+
+- [x] 3. Checkpoint - 确保后端测试通过
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 4. 更新前端仪表盘月度报告
+  - [x] 4.1 修改 `Dashboard.jsx` 月度报告统计卡片区域
+    - 增加"已结算收入"统计卡片(绿色图标)
+    - 增加"未结算收入"统计卡片(橙色图标)
+    - _Requirements: 1.1, 1.2_
+  - [x] 4.2 修改人员按供应商收入明细表格
+    - 增加"结算状态"列
+    - 未结算行使用浅橙色背景 (#fff7e6)
+    - _Requirements: 2.1, 2.2_
+
+- [x] 5. 更新前端仪表盘年度汇总
+  - [x] 5.1 修改 `Dashboard.jsx` 年度汇总表格
+    - 增加"已结算"和"未结算"列
+    - 显示总计行的已结算/未结算金额
+    - _Requirements: 3.1, 3.3_
+  - [x] 5.2 调整年度汇总位置到仪表盘底部
+    - 将年度汇总Card移动到所有其他部分之后
+    - _Requirements: 4.1_
+
+- [x] 6. Checkpoint - 确保前端功能正常
+  - 确保仪表盘正确显示结算状态,如有问题请询问用户
+
+- [x] 7. 扩展月度报表导出功能
+  - [x] 7.1 修改 `export_service.py` 的月度明细表
+    - 在 `DETAIL_HEADERS` 增加"结算状态"列
+    - 在 `_create_detail_sheet()` 中输出结算状态
+    - _Requirements: 5.1, 5.3_
+  - [x] 7.2 修改 `export_service.py` 的月度汇总表
+    - 在 `_create_monthly_summary_sheet()` 中增加结算状态列
+    - 按人员+供应商+结算状态分组汇总
+    - _Requirements: 5.2, 5.3_
+
+- [x] 8. 扩展年度报表导出功能
+  - [x] 8.1 修改 `export_service.py` 的年度明细表
+    - 复用明细表的结算状态列(已在7.1实现)
+    - _Requirements: 6.1, 6.3_
+  - [x] 8.2 修改 `export_service.py` 的年度汇总表
+    - 在 `_create_yearly_summary_sheet()` 中增加已结算/未结算列
+    - _Requirements: 6.2, 6.3_
+  - [ ]* 8.3 编写导出结算状态映射属性测试
+    - **Property 4: Export Settlement Status Text Mapping**
+    - **Validates: Requirements 5.3, 6.3**
+
+- [x] 9. Final Checkpoint - 确保所有测试通过
+  - 确保所有测试通过,如有问题请询问用户
+
+## Notes
+
+- 任务标记 `*` 的为可选测试任务,可跳过以加快MVP开发
+- 数据库已有 `is_settled` 字段,无需数据库迁移
+- 属性测试使用 Hypothesis 库
+- 每个任务都引用了具体的需求以便追溯

+ 447 - 0
.kiro/specs/supplier-management/design.md

@@ -0,0 +1,447 @@
+# Design Document: Supplier Management
+
+## Overview
+
+本设计文档描述供应商管理功能的技术实现方案,包括:
+- 人员和物品名称唯一性约束
+- 供应商管理模块(CRUD)
+- 物品关联供应商
+- 工作记录结算状态管理
+- 导出报表增加供应商信息
+- 仪表盘供应商相关统计
+- 数据库迁移脚本
+
+## Architecture
+
+### 系统架构
+
+```mermaid
+graph TB
+    subgraph Frontend
+        Dashboard[Dashboard.jsx]
+        SupplierMgmt[SupplierManagement.jsx]
+        ItemMgmt[ItemManagement.jsx]
+        WorkRecordMgmt[WorkRecordManagement.jsx]
+    end
+    
+    subgraph Backend API
+        SupplierRoutes[/api/suppliers]
+        PersonRoutes[/api/persons]
+        ItemRoutes[/api/items]
+        WorkRecordRoutes[/api/work-records]
+        ExportRoutes[/api/export]
+    end
+    
+    subgraph Services
+        ExportService[ExportService]
+    end
+    
+    subgraph Models
+        Supplier[Supplier Model]
+        Person[Person Model]
+        Item[Item Model]
+        WorkRecord[WorkRecord Model]
+    end
+    
+    subgraph Database
+        DB[(SQLite/PostgreSQL)]
+    end
+    
+    Dashboard --> WorkRecordRoutes
+    SupplierMgmt --> SupplierRoutes
+    ItemMgmt --> ItemRoutes
+    WorkRecordMgmt --> WorkRecordRoutes
+    
+    SupplierRoutes --> Supplier
+    PersonRoutes --> Person
+    ItemRoutes --> Item
+    WorkRecordRoutes --> WorkRecord
+    ExportRoutes --> ExportService
+    
+    ExportService --> WorkRecord
+    ExportService --> Item
+    ExportService --> Supplier
+    
+    Supplier --> DB
+    Person --> DB
+    Item --> DB
+    WorkRecord --> DB
+```
+
+## Components and Interfaces
+
+### 1. Supplier Model (Backend)
+
+```python
+class Supplier(db.Model):
+    __tablename__ = 'suppliers'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, unique=True, index=True)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), 
+                          onupdate=lambda: datetime.now(timezone.utc))
+    
+    def to_dict(self):
+        return {
+            'id': self.id,
+            'name': self.name,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+```
+
+### 2. Updated Item Model
+
+```python
+class Item(db.Model):
+    __tablename__ = 'items'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, unique=True, index=True)
+    unit_price = db.Column(db.Float, nullable=False)
+    supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=True)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
+                          onupdate=lambda: datetime.now(timezone.utc))
+    
+    supplier = db.relationship('Supplier', backref=db.backref('items', lazy='dynamic'))
+    
+    def to_dict(self):
+        return {
+            'id': self.id,
+            'name': self.name,
+            'unit_price': self.unit_price,
+            'supplier_id': self.supplier_id,
+            'supplier_name': self.supplier.name if self.supplier else '',
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+```
+
+### 3. Updated Person Model
+
+```python
+class Person(db.Model):
+    __tablename__ = 'persons'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, unique=True, index=True)
+    # ... rest unchanged
+```
+
+### 4. Updated WorkRecord Model
+
+```python
+class WorkRecord(db.Model):
+    __tablename__ = 'work_records'
+    
+    # ... existing fields ...
+    is_settled = db.Column(db.Boolean, nullable=False, default=False)
+    
+    def to_dict(self):
+        return {
+            # ... existing fields ...
+            'is_settled': self.is_settled,
+            'supplier_id': self.item.supplier_id if self.item else None,
+            'supplier_name': self.item.supplier.name if self.item and self.item.supplier else ''
+        }
+```
+
+### 5. Supplier API Routes
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | /api/suppliers | 获取所有供应商列表 |
+| GET | /api/suppliers/:id | 获取单个供应商详情 |
+| POST | /api/suppliers | 创建新供应商 |
+| PUT | /api/suppliers/:id | 更新供应商信息 |
+| DELETE | /api/suppliers/:id | 删除供应商 |
+
+### 6. Updated Work Record API Routes
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| PUT | /api/work-records/:id/settlement | 切换单条记录结算状态 |
+| POST | /api/work-records/batch-settlement | 批量更新结算状态 |
+
+**批量更新请求体:**
+```json
+{
+  "person_id": 1,        // 可选
+  "year": 2024,          // 必填
+  "month": 12,           // 必填
+  "supplier_id": 1,      // 可选
+  "is_settled": true     // 必填
+}
+```
+
+### 7. Frontend Components
+
+#### SupplierManagement.jsx
+- 供应商列表表格
+- 新增/编辑供应商模态框
+- 删除确认对话框
+
+#### Updated ItemManagement.jsx
+- 物品表单增加供应商下拉选择
+- 物品列表增加供应商列
+
+#### Updated WorkRecordManagement.jsx
+- 工作记录列表增加结算状态列
+- 单条记录结算状态切换按钮
+- 批量操作模态框(人员、月份、供应商筛选)
+
+#### Updated Dashboard.jsx
+- 月度报告物品明细增加供应商列
+- 新增人员按供应商收入明细表格
+- 工作统计详情增加供应商列
+- 系统统计显示供应商数量
+
+## Data Models
+
+### Entity Relationship Diagram
+
+```mermaid
+erDiagram
+    SUPPLIER {
+        int id PK
+        string name UK
+        datetime created_at
+        datetime updated_at
+    }
+    
+    PERSON {
+        int id PK
+        string name UK
+        datetime created_at
+        datetime updated_at
+    }
+    
+    ITEM {
+        int id PK
+        string name UK
+        float unit_price
+        int supplier_id FK
+        datetime created_at
+        datetime updated_at
+    }
+    
+    WORK_RECORD {
+        int id PK
+        int person_id FK
+        int item_id FK
+        date work_date
+        int quantity
+        boolean is_settled
+        datetime created_at
+        datetime updated_at
+    }
+    
+    SUPPLIER ||--o{ ITEM : "supplies"
+    PERSON ||--o{ WORK_RECORD : "creates"
+    ITEM ||--o{ WORK_RECORD : "used_in"
+```
+
+### Database Migration Script
+
+迁移脚本需要兼容 SQLite 和 PostgreSQL:
+
+```python
+def upgrade():
+    # 1. 创建 suppliers 表
+    op.create_table('suppliers',
+        sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column('name', sa.String(100), nullable=False),
+        sa.Column('created_at', sa.DateTime(), nullable=True),
+        sa.Column('updated_at', sa.DateTime(), nullable=True),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('ix_suppliers_name', 'suppliers', ['name'], unique=True)
+    
+    # 2. 为 persons 表添加唯一约束
+    op.create_index('ix_persons_name_unique', 'persons', ['name'], unique=True)
+    
+    # 3. 为 items 表添加唯一约束和 supplier_id
+    op.create_index('ix_items_name_unique', 'items', ['name'], unique=True)
+    op.add_column('items', sa.Column('supplier_id', sa.Integer(), nullable=True))
+    op.create_foreign_key('fk_items_supplier', 'items', 'suppliers', ['supplier_id'], ['id'])
+    
+    # 4. 为 work_records 表添加 is_settled
+    op.add_column('work_records', sa.Column('is_settled', sa.Boolean(), 
+                  nullable=False, server_default='0'))
+
+def downgrade():
+    op.drop_column('work_records', 'is_settled')
+    op.drop_constraint('fk_items_supplier', 'items', type_='foreignkey')
+    op.drop_column('items', 'supplier_id')
+    op.drop_index('ix_items_name_unique', 'items')
+    op.drop_index('ix_persons_name_unique', 'persons')
+    op.drop_index('ix_suppliers_name', 'suppliers')
+    op.drop_table('suppliers')
+```
+
+
+
+## Export Service Updates
+
+### Updated Detail Sheet Headers
+```python
+DETAIL_HEADERS = ['人员', '日期', '供应商', '物品', '单价', '数量', '总价']
+```
+
+### Updated Monthly Summary Sheet
+月度汇总按人员和供应商分组:
+```python
+# Headers: ['人员', '供应商', '总金额']
+# 按 (person_name, supplier_name) 分组统计
+```
+
+### Yearly Summary Sheet
+保持现有格式不变。
+
+## Dashboard API Updates
+
+### Monthly Summary Response
+```json
+{
+  "total_records": 100,
+  "total_earnings": 5000.00,
+  "top_performers": [...],
+  "item_breakdown": [
+    {
+      "item_id": 1,
+      "item_name": "物品A",
+      "supplier_name": "供应商X",
+      "quantity": 50,
+      "earnings": 1000.00
+    }
+  ],
+  "supplier_breakdown": [
+    {
+      "person_id": 1,
+      "person_name": "张三",
+      "supplier_name": "供应商X",
+      "earnings": 500.00
+    }
+  ]
+}
+```
+
+### Daily Summary Response
+```json
+{
+  "summary": [
+    {
+      "person_id": 1,
+      "person_name": "张三",
+      "supplier_name": "供应商X",
+      "total_items": 10,
+      "total_value": 200.00
+    }
+  ],
+  "grand_total_items": 100,
+  "grand_total_value": 2000.00
+}
+```
+
+
+
+## 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: Person Name Uniqueness
+*For any* two persons in the system, their names must be different. Creating or updating a person with an existing name should fail with an error.
+**Validates: Requirements 1.1, 1.2, 1.3**
+
+### Property 2: Item Name Uniqueness
+*For any* two items in the system, their names must be different. Creating or updating an item with an existing name should fail with an error.
+**Validates: Requirements 2.1, 2.2, 2.3**
+
+### Property 3: Supplier Name Uniqueness
+*For any* two suppliers in the system, their names must be different. Creating or updating a supplier with an existing name should fail with an error.
+**Validates: Requirements 3.2, 3.3, 3.6**
+
+### Property 4: Empty Supplier Name Display
+*For any* item without a supplier (supplier_id is null), the supplier_name field in API responses, export reports, and dashboard displays should be an empty string.
+**Validates: Requirements 4.2, 6.4, 7.3, 8.2**
+
+### Property 5: Work Record Settlement Default
+*For any* newly created work record, the is_settled field should default to False.
+**Validates: Requirements 5.1**
+
+### Property 6: Settlement Status Toggle
+*For any* work record, calling the settlement toggle API should flip the is_settled value (True becomes False, False becomes True).
+**Validates: Requirements 5.3**
+
+### Property 7: Batch Settlement Filter Intersection
+*For any* batch settlement request with person_id, year/month, and supplier_id filters, only work records matching ALL specified criteria should be updated.
+**Validates: Requirements 5.5, 5.7**
+
+## Error Handling
+
+### API Error Responses
+
+| Error Code | Description | HTTP Status |
+|------------|-------------|-------------|
+| DUPLICATE_NAME | 名称已存在 | 400 |
+| NOT_FOUND | 资源不存在 | 404 |
+| INVALID_INPUT | 输入参数无效 | 400 |
+| FOREIGN_KEY_VIOLATION | 外键约束违反(如删除有关联物品的供应商) | 400 |
+
+### Error Response Format
+```json
+{
+  "error": "DUPLICATE_NAME",
+  "message": "供应商名称已存在"
+}
+```
+
+### Constraint Violation Handling
+- 删除供应商时,如果有物品关联该供应商,返回错误提示
+- 或者可以选择将关联物品的 supplier_id 设为 null
+
+## Testing Strategy
+
+### Unit Tests
+- Supplier CRUD operations
+- Person/Item name uniqueness validation
+- WorkRecord settlement status operations
+- Export service with supplier data
+- Dashboard API with supplier statistics
+
+### Property-Based Tests
+使用 `hypothesis` 库进行属性测试:
+
+1. **Name Uniqueness Property Test**
+   - 生成随机名称,验证重复创建失败
+   - 最少 100 次迭代
+
+2. **Empty Supplier Display Property Test**
+   - 生成无供应商的物品和工作记录
+   - 验证所有输出中 supplier_name 为空字符串
+
+3. **Settlement Toggle Property Test**
+   - 生成随机工作记录
+   - 验证切换操作的幂等性(切换两次回到原状态)
+
+4. **Batch Settlement Filter Property Test**
+   - 生成随机工作记录集合
+   - 验证批量更新只影响符合所有条件的记录
+
+### Integration Tests
+- 完整的供应商管理流程
+- 物品关联供应商流程
+- 工作记录批量结算流程
+- 导出报表包含供应商数据
+
+### Test Configuration
+```python
+# conftest.py
+from hypothesis import settings
+
+settings.register_profile("ci", max_examples=100)
+settings.load_profile("ci")
+```
+

+ 131 - 0
.kiro/specs/supplier-management/requirements.md

@@ -0,0 +1,131 @@
+# Requirements Document
+
+## Introduction
+
+本功能为工作统计系统增加供应商管理模块,并对现有功能进行增强:确保人员和物品名称唯一性、物品关联供应商、工作记录增加结算状态、导出报表增加供应商信息、仪表盘增加供应商相关统计。
+
+## Glossary
+
+- **System**: 工作统计系统
+- **Supplier**: 供应商实体,包含唯一名称
+- **Person**: 人员实体,名称必须唯一
+- **Item**: 物品实体,名称必须唯一,可关联供应商
+- **WorkRecord**: 工作记录实体,包含结算状态
+- **Settlement_Status**: 工作记录的结算状态(已结算/未结算)
+- **Export_Service**: 导出报表服务
+- **Dashboard**: 仪表盘组件
+- **Migration_Script**: 数据库迁移脚本
+
+## Requirements
+
+### Requirement 1: 人员名称唯一性
+
+**User Story:** As a 系统管理员, I want 人员名称是唯一的, so that 导入数据时可以通过名称准确匹配人员。
+
+#### Acceptance Criteria
+
+1. WHEN 创建人员时输入的名称已存在, THEN THE System SHALL 拒绝创建并返回错误信息
+2. WHEN 更新人员名称为已存在的名称, THEN THE System SHALL 拒绝更新并返回错误信息
+3. THE Person 数据库表 SHALL 对 name 字段添加唯一约束
+
+### Requirement 2: 物品名称唯一性
+
+**User Story:** As a 系统管理员, I want 物品名称是唯一的, so that 导入数据时可以通过名称准确匹配物品。
+
+#### Acceptance Criteria
+
+1. WHEN 创建物品时输入的名称已存在, THEN THE System SHALL 拒绝创建并返回错误信息
+2. WHEN 更新物品名称为已存在的名称, THEN THE System SHALL 拒绝更新并返回错误信息
+3. THE Item 数据库表 SHALL 对 name 字段添加唯一约束
+
+### Requirement 3: 供应商管理模块
+
+**User Story:** As a 系统管理员, I want 管理供应商信息, so that 可以追踪物品来源。
+
+#### Acceptance Criteria
+
+1. THE Supplier SHALL 包含以下属性:id(主键)、name(供应商名称,唯一)、created_at、updated_at
+2. WHEN 创建供应商时输入的名称已存在, THEN THE System SHALL 拒绝创建并返回错误信息
+3. WHEN 更新供应商名称为已存在的名称, THEN THE System SHALL 拒绝更新并返回错误信息
+4. THE System SHALL 提供供应商的增删改查 API 接口
+5. THE System SHALL 提供供应商管理的前端界面,包含列表展示、新增、编辑、删除功能
+6. THE Supplier 数据库表 SHALL 对 name 字段添加唯一约束
+
+### Requirement 4: 物品关联供应商
+
+**User Story:** As a 系统管理员, I want 为物品设置供应商, so that 可以追踪物品来源。
+
+#### Acceptance Criteria
+
+1. THE Item SHALL 增加 supplier_id 字段,可为空(nullable)
+2. WHEN 物品未设置供应商, THEN THE System SHALL 将供应商相关字段保持为空字符串
+3. THE System SHALL 在物品表单中提供供应商选择下拉框
+4. THE System SHALL 在物品列表中显示供应商名称
+
+### Requirement 5: 工作记录结算状态
+
+**User Story:** As a 系统管理员, I want 管理工作记录的结算状态, so that 可以追踪哪些记录已结算。
+
+#### Acceptance Criteria
+
+1. THE WorkRecord SHALL 增加 is_settled 字段,类型为布尔值,默认为 False
+2. WHEN 查看工作记录列表, THEN THE System SHALL 显示每条记录的结算状态
+3. WHEN 用户点击单条记录的结算按钮, THEN THE System SHALL 切换该记录的结算状态
+4. THE System SHALL 提供批量操作窗口,包含人员选择器、月份选择器和供应商选择器
+5. WHEN 用户在批量操作窗口选择人员、月份和供应商, THEN THE System SHALL 筛选出符合所有条件(取交集)的工作记录
+6. THE System 批量操作窗口 SHALL 提供"设为已结算"和"设为未结算"两个操作按钮
+7. WHEN 用户点击批量操作按钮, THEN THE System SHALL 将筛选出的所有记录统一设置为对应的结算状态
+8. THE System SHALL 提供按人员、月份和供应商批量更新结算状态的 API 接口
+
+### Requirement 6: 导出报表增加供应商
+
+**User Story:** As a 系统管理员, I want 导出的报表包含供应商信息, so that 可以按供应商分析数据。
+
+#### Acceptance Criteria
+
+1. THE Export_Service 明细表 SHALL 包含以下列:人员、日期、供应商、物品、单价、数量、总价
+2. THE Export_Service 月度汇总表 SHALL 包含以下列:人员、供应商、总金额
+3. THE Export_Service 年度汇总表 SHALL 保持现有格式不变
+4. WHEN 物品未设置供应商, THEN THE Export_Service SHALL 在供应商列显示空字符串
+
+### Requirement 7: 仪表盘月度报告增强
+
+**User Story:** As a 用户, I want 在月度报告中看到供应商相关统计, so that 可以了解各供应商的业绩情况。
+
+#### Acceptance Criteria
+
+1. THE Dashboard 月度报告物品收入明细 SHALL 增加供应商列
+2. THE Dashboard 月度报告 SHALL 增加人员按供应商的收入明细表格
+3. WHEN 物品未设置供应商, THEN THE Dashboard SHALL 在供应商列显示空字符串
+
+### Requirement 8: 仪表盘工作统计详情增强
+
+**User Story:** As a 用户, I want 在工作统计详情中看到供应商信息, so that 可以了解当日各供应商的工作情况。
+
+#### Acceptance Criteria
+
+1. THE Dashboard 工作统计详情表格 SHALL 增加供应商列
+2. WHEN 物品未设置供应商, THEN THE Dashboard SHALL 在供应商列显示空字符串
+
+### Requirement 9: 仪表盘系统统计增强
+
+**User Story:** As a 用户, I want 在仪表盘看到供应商数量统计, so that 可以了解系统中的供应商规模。
+
+#### Acceptance Criteria
+
+1. THE Dashboard 系统统计卡片 SHALL 将"系统人员/物品"修改为"系统人员/物品/供应商"
+2. THE Dashboard SHALL 显示供应商总数
+
+### Requirement 10: 数据库迁移
+
+**User Story:** As a 开发者, I want 有数据库迁移脚本, so that 可以安全地升级现有数据库。
+
+#### Acceptance Criteria
+
+1. THE Migration_Script SHALL 创建 suppliers 表
+2. THE Migration_Script SHALL 为 persons 表的 name 字段添加唯一约束
+3. THE Migration_Script SHALL 为 items 表的 name 字段添加唯一约束
+4. THE Migration_Script SHALL 为 items 表添加 supplier_id 外键字段
+5. THE Migration_Script SHALL 为 work_records 表添加 is_settled 字段,默认值为 False
+6. THE Migration_Script SHALL 兼容 PostgreSQL 和 SQLite 数据库
+7. WHEN 执行迁移脚本, THEN THE System SHALL 保留现有数据不丢失

+ 184 - 0
.kiro/specs/supplier-management/tasks.md

@@ -0,0 +1,184 @@
+# Implementation Plan: Supplier Management
+
+## Overview
+
+本实现计划将供应商管理功能分解为可执行的编码任务,按照数据库迁移 → 后端模型 → API → 前端的顺序实现。
+
+## Tasks
+
+- [x] 1. 数据库迁移脚本
+  - [x] 1.1 创建迁移脚本文件 `backend/migrations/add_supplier_and_settlement.py`
+    - 创建 suppliers 表(id, name, created_at, updated_at)
+    - 为 suppliers.name 添加唯一索引
+    - 为 persons.name 添加唯一索引
+    - 为 items.name 添加唯一索引
+    - 为 items 表添加 supplier_id 外键字段
+    - 为 work_records 表添加 is_settled 字段(默认 False)
+    - 确保兼容 PostgreSQL 和 SQLite
+    - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
+
+- [x] 2. 后端 Supplier 模型和 API
+  - [x] 2.1 创建 Supplier 模型 `backend/app/models/supplier.py`
+    - 定义 Supplier 类,包含 id, name, created_at, updated_at
+    - 添加 to_dict() 方法
+    - 在 `__init__.py` 中注册模型
+    - _Requirements: 3.1, 3.6_
+  - [x] 2.2 创建 Supplier 路由 `backend/app/routes/supplier.py`
+    - GET /api/suppliers - 获取所有供应商
+    - GET /api/suppliers/:id - 获取单个供应商
+    - POST /api/suppliers - 创建供应商(验证名称唯一性)
+    - PUT /api/suppliers/:id - 更新供应商(验证名称唯一性)
+    - DELETE /api/suppliers/:id - 删除供应商
+    - 在 `__init__.py` 中注册蓝图
+    - _Requirements: 3.2, 3.3, 3.4_
+  - [ ]* 2.3 编写 Supplier 单元测试 `backend/tests/test_supplier.py`
+    - 测试 CRUD 操作
+    - 测试名称唯一性约束
+    - _Requirements: 3.2, 3.3, 3.4_
+  - [ ]* 2.4 编写供应商名称唯一性属性测试
+    - **Property 3: Supplier Name Uniqueness**
+    - **Validates: Requirements 3.2, 3.3, 3.6**
+
+- [x] 3. 更新 Person 模型和 API
+  - [x] 3.1 更新 Person 模型添加唯一约束验证
+    - 在创建和更新时验证名称唯一性
+    - _Requirements: 1.1, 1.2, 1.3_
+  - [x] 3.2 更新 Person 路由添加唯一性错误处理
+    - POST /api/persons - 验证名称唯一性
+    - PUT /api/persons/:id - 验证名称唯一性
+    - _Requirements: 1.1, 1.2_
+  - [ ]* 3.3 编写人员名称唯一性属性测试
+    - **Property 1: Person Name Uniqueness**
+    - **Validates: Requirements 1.1, 1.2, 1.3**
+
+- [x] 4. 更新 Item 模型和 API
+  - [x] 4.1 更新 Item 模型
+    - 添加 supplier_id 外键字段
+    - 添加 supplier 关系
+    - 更新 to_dict() 返回 supplier_name(无供应商时为空字符串)
+    - 添加唯一约束验证
+    - _Requirements: 2.1, 2.2, 2.3, 4.1, 4.2_
+  - [x] 4.2 更新 Item 路由
+    - POST /api/items - 支持 supplier_id,验证名称唯一性
+    - PUT /api/items/:id - 支持 supplier_id,验证名称唯一性
+    - _Requirements: 2.1, 2.2, 4.1_
+  - [ ]* 4.3 编写物品名称唯一性属性测试
+    - **Property 2: Item Name Uniqueness**
+    - **Validates: Requirements 2.1, 2.2, 2.3**
+  - [ ]* 4.4 编写空供应商显示属性测试
+    - **Property 4: Empty Supplier Name Display**
+    - **Validates: Requirements 4.2, 6.4, 7.3, 8.2**
+
+- [x] 5. Checkpoint - 确保所有测试通过
+  - 运行所有后端测试
+  - 确保迁移脚本正确执行
+  - 如有问题请询问用户
+
+- [x] 6. 更新 WorkRecord 模型和 API
+  - [x] 6.1 更新 WorkRecord 模型
+    - 添加 is_settled 字段(默认 False)
+    - 更新 to_dict() 返回 is_settled 和 supplier_name
+    - _Requirements: 5.1, 5.2_
+  - [x] 6.2 添加结算状态 API
+    - PUT /api/work-records/:id/settlement - 切换单条记录结算状态
+    - POST /api/work-records/batch-settlement - 批量更新结算状态
+      - 参数:person_id(可选), year, month, supplier_id(可选), is_settled
+    - _Requirements: 5.3, 5.7, 5.8_
+  - [ ]* 6.3 编写结算状态属性测试
+    - **Property 5: Work Record Settlement Default**
+    - **Property 6: Settlement Status Toggle**
+    - **Validates: Requirements 5.1, 5.3**
+  - [ ]* 6.4 编写批量结算筛选属性测试
+    - **Property 7: Batch Settlement Filter Intersection**
+    - **Validates: Requirements 5.5, 5.7**
+
+- [x] 7. 更新导出服务
+  - [x] 7.1 更新 ExportService 明细表
+    - 修改 DETAIL_HEADERS 为 ['人员', '日期', '供应商', '物品', '单价', '数量', '总价']
+    - 更新 _create_detail_sheet() 添加供应商列
+    - _Requirements: 6.1, 6.4_
+  - [x] 7.2 更新 ExportService 月度汇总表
+    - 修改 _create_monthly_summary_sheet() 按人员+供应商分组
+    - Headers: ['人员', '供应商', '总金额']
+    - _Requirements: 6.2_
+  - [ ]* 7.3 编写导出服务单元测试
+    - 测试明细表包含供应商列
+    - 测试月度汇总按人员+供应商分组
+    - 测试无供应商时显示空字符串
+    - _Requirements: 6.1, 6.2, 6.3, 6.4_
+
+- [x] 8. 更新仪表盘 API
+  - [x] 8.1 更新月度统计 API
+    - 修改 item_breakdown 返回 supplier_name
+    - 添加 supplier_breakdown 返回人员按供应商的收入明细
+    - _Requirements: 7.1, 7.2, 7.3_
+  - [x] 8.2 更新日统计 API
+    - 修改 summary 返回 supplier_name
+    - _Requirements: 8.1, 8.2_
+  - [ ]* 8.3 编写仪表盘 API 单元测试
+    - 测试月度统计包含供应商数据
+    - 测试日统计包含供应商数据
+    - _Requirements: 7.1, 7.2, 8.1_
+
+- [x] 9. Checkpoint - 确保后端功能完整
+  - 运行所有后端测试
+  - 验证 API 响应格式正确
+  - 如有问题请询问用户
+
+- [x] 10. 前端 Supplier 管理页面
+  - [x] 10.1 创建 Supplier API 服务 `frontend/src/services/api.js`
+    - 添加 supplierApi 对象(getAll, getById, create, update, delete)
+    - _Requirements: 3.4_
+  - [x] 10.2 创建 SupplierManagement 组件 `frontend/src/components/SupplierManagement.jsx`
+    - 供应商列表表格
+    - 新增/编辑模态框
+    - 删除确认对话框
+    - 名称唯一性错误提示
+    - _Requirements: 3.5_
+  - [x] 10.3 更新路由配置添加供应商管理页面
+    - 在 App.jsx 添加 /suppliers 路由
+    - 在导航菜单添加供应商管理入口
+    - _Requirements: 3.5_
+
+- [x] 11. 前端 Item 管理页面更新
+  - [x] 11.1 更新 ItemManagement 组件
+    - 物品表单添加供应商下拉选择
+    - 物品列表添加供应商列
+    - _Requirements: 4.3, 4.4_
+
+- [x] 12. 前端 WorkRecord 管理页面更新
+  - [x] 12.1 更新 WorkRecordManagement 组件
+    - 工作记录列表添加结算状态列
+    - 添加单条记录结算状态切换按钮
+    - _Requirements: 5.2, 5.3_
+  - [x] 12.2 添加批量结算操作模态框
+    - 人员选择器
+    - 月份选择器
+    - 供应商选择器
+    - "设为已结算"和"设为未结算"按钮
+    - _Requirements: 5.4, 5.5, 5.6, 5.7_
+
+- [x] 13. 前端 Dashboard 更新
+  - [x] 13.1 更新月度报告部分
+    - 物品收入明细表格添加供应商列
+    - 添加人员按供应商收入明细表格
+    - _Requirements: 7.1, 7.2_
+  - [x] 13.2 更新工作统计详情
+    - 表格添加供应商列
+    - _Requirements: 8.1_
+  - [x] 13.3 更新系统统计卡片
+    - 修改标题为"系统人员/物品/供应商"
+    - 显示供应商总数
+    - _Requirements: 9.1, 9.2_
+
+- [x] 14. Final Checkpoint - 完整功能验证
+  - 确保所有测试通过
+  - 验证前后端集成正常
+  - 如有问题请询问用户
+
+## Notes
+
+- 任务标记 `*` 为可选测试任务,可跳过以加快 MVP 开发
+- 每个任务引用具体的需求编号以便追溯
+- Checkpoint 任务用于阶段性验证
+- 属性测试验证核心正确性属性

+ 267 - 0
.kiro/specs/ui-enhancements/design.md

@@ -0,0 +1,267 @@
+# Design Document: UI Enhancements
+
+## Overview
+
+本设计对现有系统进行多项UI增强和优化:
+1. 全局隐藏所有表格中的ID列,使界面更简洁
+2. 工作记录列表增加结算状态筛选功能
+3. 仪表盘年度汇总结算状态显示优化
+4. 导出报表中未结算状态的视觉标注
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                      Frontend (React)                        │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │              List Components (5个)                   │    │
+│  │  - PersonList.jsx: 移除ID列                         │    │
+│  │  - ItemList.jsx: 移除ID列                           │    │
+│  │  - SupplierList.jsx: 移除ID列                       │    │
+│  │  - WorkRecordList.jsx: 移除ID列 + 增加结算筛选      │    │
+│  │  - AdminList.jsx: 移除ID列                          │    │
+│  └─────────────────────────────────────────────────────┘    │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │                   Dashboard.jsx                      │    │
+│  │  - 年度汇总: 合并结算状态列,调整位置               │    │
+│  │  - 人员按供应商明细: 已结算状态黑色字体             │    │
+│  └─────────────────────────────────────────────────────┘    │
+└─────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────┐
+│                      Backend (Flask)                         │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │              work_record_service.py                  │    │
+│  │  - get_all(): 支持 is_settled 筛选参数              │    │
+│  └─────────────────────────────────────────────────────┘    │
+│  ┌─────────────────────────────────────────────────────┐    │
+│  │               export_service.py                      │    │
+│  │  - 年度汇总: 未结算列底色标注                       │    │
+│  │  - 明细表: 未结算行底色标注                         │    │
+│  └─────────────────────────────────────────────────────┘    │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Components and Interfaces
+
+### Frontend Component Changes
+
+#### 1. List Components - 隐藏ID列
+
+需要修改的组件:
+- `PersonList.jsx` - 移除 ID 列定义
+- `ItemList.jsx` - 移除 ID 列定义
+- `SupplierList.jsx` - 移除 ID 列定义
+- `WorkRecordList.jsx` - 移除 ID 列定义
+- `AdminList.jsx` - 移除 ID 列定义
+
+修改方式:从 `columns` 数组中删除 `{ title: 'ID', dataIndex: 'id', ... }` 对象。
+
+#### 2. WorkRecordList - 增加结算状态筛选
+
+在筛选区域增加结算状态下拉框:
+
+```jsx
+// 新增状态
+const [selectedSettlement, setSelectedSettlement] = useState(null)
+
+// 筛选选项
+const settlementOptions = [
+  { label: '全部', value: null },
+  { label: '已结算', value: true },
+  { label: '未结算', value: false }
+]
+
+// 在筛选区域增加
+<Select
+  placeholder="结算状态"
+  allowClear
+  style={{ width: '100%' }}
+  value={selectedSettlement}
+  onChange={setSelectedSettlement}
+  options={settlementOptions}
+/>
+```
+
+API调用时传递 `is_settled` 参数:
+```javascript
+if (selectedSettlement !== null) {
+  params.is_settled = selectedSettlement
+}
+```
+
+#### 3. Dashboard - 年度汇总结算状态优化
+
+将"已结算"和"未结算"两列合并为单一"结算状态"列,放在"人员"列后面:
+
+```jsx
+const yearlySummaryColumns = [
+  {
+    title: '人员',
+    dataIndex: 'person_name',
+    key: 'person_name',
+    fixed: 'left',
+    width: 100
+  },
+  {
+    title: '结算状态',
+    key: 'settlement_status',
+    width: 180,
+    render: (_, record) => (
+      <span>
+        已结算: ¥{(record.settled_total || 0).toFixed(2)} / 
+        未结算: ¥{(record.unsettled_total || 0).toFixed(2)}
+      </span>
+    )
+  },
+  // ... 月份列 ...
+  {
+    title: '年度合计',
+    dataIndex: 'yearly_total',
+    // ...
+  }
+]
+```
+
+#### 4. Dashboard - 人员按供应商明细已结算状态字体颜色
+
+修改 `supplierBreakdownColumns` 中的结算状态列渲染:
+
+```jsx
+{
+  title: '结算状态',
+  dataIndex: 'is_settled',
+  key: 'is_settled',
+  align: 'center',
+  render: (value) => value ? (
+    <span style={{ color: '#000000' }}>已结算</span>  // 黑色
+  ) : (
+    <span style={{ color: '#fa8c16' }}>未结算</span>  // 保持橙色
+  )
+}
+```
+
+### Backend API Changes
+
+#### Work Record Service - 支持结算状态筛选
+
+修改 `work_record_service.py` 的 `get_all()` 方法,支持 `is_settled` 查询参数:
+
+```python
+@staticmethod
+def get_all(person_id=None, date=None, year=None, month=None, is_settled=None):
+    query = WorkRecord.query
+    
+    # ... 现有筛选逻辑 ...
+    
+    # 新增结算状态筛选
+    if is_settled is not None:
+        query = query.filter(WorkRecord.is_settled == is_settled)
+    
+    return query.order_by(WorkRecord.work_date.desc()).all()
+```
+
+修改路由接收参数:
+```python
+is_settled = request.args.get('is_settled')
+if is_settled is not None:
+    is_settled = is_settled.lower() == 'true'
+```
+
+### Export Service Changes
+
+#### 1. 年度汇总未结算列底色标注
+
+使用浅橙色背景 (#FFF2E8) 标注"未结算"列:
+
+```python
+from openpyxl.styles import PatternFill
+
+UNSETTLED_FILL = PatternFill(start_color='FFF2E8', end_color='FFF2E8', fill_type='solid')
+
+# 在 _create_yearly_summary_sheet 中
+# 为未结算列标题应用底色
+cell = ws.cell(row=1, column=16, value='未结算')
+cell.fill = UNSETTLED_FILL
+
+# 为未结算列数据单元格应用底色
+cell = ws.cell(row=row_idx, column=16, value=round(unsettled, 2))
+cell.fill = UNSETTLED_FILL
+```
+
+#### 2. 明细表未结算行底色标注
+
+在 `_create_detail_sheet` 中,为未结算记录的整行应用底色:
+
+```python
+# 在写入数据行时
+for row_idx, record in enumerate(records, 2):
+    # ... 写入数据 ...
+    
+    # 如果是未结算记录,为整行应用底色
+    if not record.is_settled:
+        for col_idx in range(1, len(ExportService.DETAIL_HEADERS) + 1):
+            ws.cell(row=row_idx, column=col_idx).fill = UNSETTLED_FILL
+```
+
+## Data Models
+
+无需修改数据模型。
+
+## 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: Settlement Filter Correctness
+
+*For any* set of work records and any settlement filter value (true, false, or null), the filtered results SHALL contain only records matching the filter criteria: when filter is true, all returned records have is_settled=true; when filter is false, all returned records have is_settled=false; when filter is null, all records are returned.
+
+**Validates: Requirements 2.2, 2.3, 2.4, 2.5**
+
+### Property 2: Unsettled Row Highlighting in Exports
+
+*For any* exported report (monthly or yearly), all rows in the detail sheet where settlement status is "未结算" SHALL have the same distinct background color applied to all cells in that row.
+
+**Validates: Requirements 6.1, 6.2, 6.3**
+
+### Property 3: Unsettled Column Highlighting in Yearly Summary
+
+*For any* yearly report export, all cells in the "未结算" column (including header) in the yearly summary sheet SHALL have a distinct background color applied.
+
+**Validates: Requirements 5.1, 5.2**
+
+## Error Handling
+
+1. **筛选参数处理**
+   - 如果 `is_settled` 参数值无效,忽略该参数并返回所有记录
+   - 保持现有的错误处理模式
+
+2. **导出样式处理**
+   - 如果样式应用失败,继续导出但不应用样式
+   - 记录错误日志但不中断导出流程
+
+## Testing Strategy
+
+### Unit Tests
+
+1. **Backend Service Tests**
+   - 测试 `get_all()` 方法正确处理 `is_settled` 参数
+   - 测试导出服务正确应用未结算行/列底色
+
+2. **Frontend Component Tests**
+   - 测试各列表组件不显示ID列
+   - 测试工作记录列表的结算状态筛选功能
+   - 测试仪表盘年度汇总的结算状态列格式
+
+### Property-Based Tests
+
+使用 Hypothesis 库进行属性测试:
+
+1. **Settlement Filter Property Test**
+   - 生成随机工作记录集合
+   - 验证筛选结果符合筛选条件
+
+2. **Export Styling Property Test**
+   - 生成随机工作记录
+   - 验证导出文件中未结算记录的样式正确应用

+ 84 - 0
.kiro/specs/ui-enhancements/requirements.md

@@ -0,0 +1,84 @@
+# Requirements Document
+
+## Introduction
+
+本功能对现有系统进行多项UI增强和优化,包括:
+1. 全局隐藏表格中的ID列
+2. 工作记录增加结算状态筛选
+3. 仪表盘年度汇总结算状态显示优化
+4. 导出报表未结算状态的视觉标注
+
+## Glossary
+
+- **Table_Component**: 系统中的各类数据表格组件,包括人员列表、物品列表、供应商列表、工作记录列表等
+- **Work_Record_List**: 工作记录列表组件,显示工作记录数据
+- **Dashboard**: 仪表盘组件,显示日统计、月度报告和年度汇总
+- **Yearly_Summary**: 年度汇总,显示全年按月的人员收入统计
+- **Export_Service**: 导出服务,生成Excel格式的月度和年度报表
+- **Settlement_Status**: 结算状态,标识工作记录是否已结算(已结算/未结算)
+- **Settlement_Filter**: 结算状态筛选器,用于按结算状态过滤工作记录
+
+## Requirements
+
+### Requirement 1: 全局隐藏表格ID列
+
+**User Story:** As a user, I want to hide ID columns in all tables, so that the interface is cleaner and focuses on meaningful data.
+
+#### Acceptance Criteria
+
+1. WHEN the PersonList component is rendered, THE Table_Component SHALL NOT display the ID column
+2. WHEN the ItemList component is rendered, THE Table_Component SHALL NOT display the ID column
+3. WHEN the SupplierList component is rendered, THE Table_Component SHALL NOT display the ID column
+4. WHEN the WorkRecordList component is rendered, THE Table_Component SHALL NOT display the ID column
+5. WHEN the AdminList component is rendered, THE Table_Component SHALL NOT display the ID column
+
+### Requirement 2: 工作记录结算状态筛选
+
+**User Story:** As a user, I want to filter work records by settlement status, so that I can quickly find settled or unsettled records.
+
+#### Acceptance Criteria
+
+1. WHEN the WorkRecordList is displayed, THE Work_Record_List SHALL show a settlement status filter dropdown
+2. WHEN a user selects "已结算" filter, THE Work_Record_List SHALL display only settled work records
+3. WHEN a user selects "未结算" filter, THE Work_Record_List SHALL display only unsettled work records
+4. WHEN a user selects "全部" filter, THE Work_Record_List SHALL display all work records regardless of settlement status
+5. WHEN the settlement filter is applied, THE Work_Record_Service SHALL accept and process the is_settled query parameter
+
+### Requirement 3: 仪表盘年度汇总结算状态优化
+
+**User Story:** As a user, I want the yearly summary to show a single "结算状态" column instead of separate "已结算/未结算" columns, so that the display is more concise.
+
+#### Acceptance Criteria
+
+1. WHEN the yearly summary is displayed, THE Dashboard SHALL show a single "结算状态" column instead of separate "已结算" and "未结算" columns
+2. WHEN the yearly summary is displayed, THE Dashboard SHALL position the "结算状态" column immediately after the "人员" column
+3. WHEN the yearly summary displays settlement status, THE Dashboard SHALL show the format "已结算: ¥X / 未结算: ¥Y"
+
+### Requirement 4: 人员按供应商明细已结算状态字体颜色
+
+**User Story:** As a user, I want settled status to be displayed in black font in the person-by-supplier breakdown, so that it's visually distinct from unsettled status.
+
+#### Acceptance Criteria
+
+1. WHEN the person-by-supplier breakdown displays a row with "已结算" status, THE Dashboard SHALL render the settlement status text in black color
+2. WHEN the person-by-supplier breakdown displays a row with "未结算" status, THE Dashboard SHALL maintain the current styling (non-black color)
+
+### Requirement 5: 年度报表汇总未结算列底色标注
+
+**User Story:** As a user, I want the unsettled column in yearly summary export to have a background color, so that it stands out in the report.
+
+#### Acceptance Criteria
+
+1. WHEN a yearly report is exported, THE Export_Service SHALL apply a distinct background color to the "未结算" column header in the yearly summary sheet
+2. WHEN a yearly report is exported, THE Export_Service SHALL apply a distinct background color to all cells in the "未结算" column in the yearly summary sheet
+
+### Requirement 6: 所有报表未结算行底色标注
+
+**User Story:** As a user, I want unsettled rows in all exported reports to have a background color, so that I can easily identify pending payments.
+
+#### Acceptance Criteria
+
+1. WHEN a monthly report is exported, THE Export_Service SHALL apply a distinct background color to rows where settlement status is "未结算" in the detail sheet
+2. WHEN a yearly report is exported, THE Export_Service SHALL apply a distinct background color to rows where settlement status is "未结算" in the detail sheet
+3. WHEN applying background color to unsettled rows, THE Export_Service SHALL use a consistent color across all report types
+

+ 90 - 0
.kiro/specs/ui-enhancements/tasks.md

@@ -0,0 +1,90 @@
+# Implementation Plan: UI Enhancements
+
+## Overview
+
+本实现计划将UI增强功能分解为前端列表组件修改、工作记录筛选功能、仪表盘优化和导出服务增强四个主要部分。
+
+## Tasks
+
+- [x] 1. 隐藏所有列表组件的ID列
+  - [x] 1.1 修改 `PersonList.jsx` 移除ID列
+    - 从 columns 数组中删除 ID 列定义
+    - _Requirements: 1.1_
+  - [x] 1.2 修改 `ItemList.jsx` 移除ID列
+    - 从 columns 数组中删除 ID 列定义
+    - _Requirements: 1.2_
+  - [x] 1.3 修改 `SupplierList.jsx` 移除ID列
+    - 从 columns 数组中删除 ID 列定义
+    - _Requirements: 1.3_
+  - [x] 1.4 修改 `WorkRecordList.jsx` 移除ID列
+    - 从 columns 数组中删除 ID 列定义
+    - _Requirements: 1.4_
+  - [x] 1.5 修改 `AdminList.jsx` 移除ID列
+    - 从 columns 数组中删除 ID 列定义
+    - _Requirements: 1.5_
+
+- [x] 2. 实现工作记录结算状态筛选功能
+  - [x] 2.1 修改后端 `work_record_service.py` 支持 is_settled 筛选
+    - 在 get_all() 方法中增加 is_settled 参数
+    - 根据参数值筛选工作记录
+    - _Requirements: 2.5_
+  - [x] 2.2 修改后端路由接收 is_settled 参数
+    - 在 work_record.py 路由中解析 is_settled 查询参数
+    - _Requirements: 2.5_
+  - [x] 2.3 修改前端 `WorkRecordList.jsx` 增加结算状态筛选
+    - 增加 selectedSettlement 状态
+    - 增加结算状态筛选下拉框(全部/已结算/未结算)
+    - 在 API 调用时传递 is_settled 参数
+    - _Requirements: 2.1, 2.2, 2.3, 2.4_
+  - [ ]* 2.4 编写结算状态筛选属性测试
+    - **Property 1: Settlement Filter Correctness**
+    - **Validates: Requirements 2.2, 2.3, 2.4, 2.5**
+
+- [x] 3. Checkpoint - 确保筛选功能正常
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 4. 优化仪表盘年度汇总结算状态显示
+  - [x] 4.1 修改 `Dashboard.jsx` 年度汇总表格列定义
+    - 移除单独的"已结算"和"未结算"列
+    - 增加合并的"结算状态"列,放在"人员"列后面
+    - 显示格式为"已结算: ¥X / 未结算: ¥Y"
+    - _Requirements: 3.1, 3.2, 3.3_
+  - [x] 4.2 修改年度汇总表格的合计行
+    - 更新合计行以适应新的列结构
+    - _Requirements: 3.1_
+
+- [x] 5. 修改人员按供应商明细已结算状态字体颜色
+  - [x] 5.1 修改 `Dashboard.jsx` 中 supplierBreakdownColumns 的结算状态列
+    - 已结算状态显示为黑色字体 (#000000)
+    - 未结算状态保持橙色字体 (#fa8c16)
+    - _Requirements: 4.1, 4.2_
+
+- [x] 6. Checkpoint - 确保仪表盘显示正常
+  - 确保仪表盘正确显示结算状态,如有问题请询问用户
+
+- [x] 7. 增强导出服务未结算状态标注
+  - [x] 7.1 修改 `export_service.py` 增加未结算样式定义
+    - 定义 UNSETTLED_FILL 样式(浅橙色 #FFF2E8)
+    - _Requirements: 6.3_
+  - [x] 7.2 修改年度汇总表未结算列底色
+    - 为"未结算"列标题应用底色
+    - 为"未结算"列所有数据单元格应用底色
+    - _Requirements: 5.1, 5.2_
+  - [x] 7.3 修改明细表未结算行底色
+    - 在 _create_detail_sheet 中为未结算记录的整行应用底色
+    - 同时适用于月度和年度报表
+    - _Requirements: 6.1, 6.2_
+  - [ ]* 7.4 编写导出样式属性测试
+    - **Property 2: Unsettled Row Highlighting in Exports**
+    - **Property 3: Unsettled Column Highlighting in Yearly Summary**
+    - **Validates: Requirements 5.1, 5.2, 6.1, 6.2, 6.3**
+
+- [x] 8. Final Checkpoint - 确保所有功能正常
+  - 确保所有测试通过,如有问题请询问用户
+
+## Notes
+
+- 任务标记 `*` 的为可选测试任务,可跳过以加快MVP开发
+- 前端修改主要涉及列定义和样式调整
+- 后端修改主要涉及筛选参数处理和导出样式
+- 每个任务都引用了具体的需求以便追溯

+ 2 - 1
backend/app/models/__init__.py

@@ -3,5 +3,6 @@ from app.models.person import Person
 from app.models.item import Item
 from app.models.work_record import WorkRecord
 from app.models.admin import Admin
+from app.models.supplier import Supplier
 
-__all__ = ['Person', 'Item', 'WorkRecord', 'Admin']
+__all__ = ['Person', 'Item', 'WorkRecord', 'Admin', 'Supplier']

+ 9 - 2
backend/app/models/item.py

@@ -8,19 +8,24 @@ class Item(db.Model):
     
     Attributes:
         id: Primary key, auto-incremented
-        name: Item's name (required, non-empty)
+        name: Item's name (required, unique)
         unit_price: Price per unit (required, positive float)
+        supplier_id: Foreign key to supplier (optional)
         created_at: Timestamp when the record was created
         updated_at: Timestamp when the record was last updated
     """
     __tablename__ = 'items'
     
     id = db.Column(db.Integer, primary_key=True, autoincrement=True)
-    name = db.Column(db.String(100), nullable=False, index=True)
+    name = db.Column(db.String(100), nullable=False, unique=True, index=True)
     unit_price = db.Column(db.Float, nullable=False)
+    supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=True)
     created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
     updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
     
+    # Relationship to Supplier
+    supplier = db.relationship('Supplier', backref=db.backref('items', lazy='dynamic'))
+    
     def to_dict(self):
         """Convert model to dictionary for JSON serialization.
         
@@ -31,6 +36,8 @@ class Item(db.Model):
             'id': self.id,
             'name': self.name,
             'unit_price': self.unit_price,
+            'supplier_id': self.supplier_id,
+            'supplier_name': self.supplier.name if self.supplier else '',
             'created_at': self.created_at.isoformat() if self.created_at else None,
             'updated_at': self.updated_at.isoformat() if self.updated_at else None
         }

+ 36 - 0
backend/app/models/supplier.py

@@ -0,0 +1,36 @@
+"""Supplier model for Work Statistics System."""
+from datetime import datetime, timezone
+from app import db
+
+
+class Supplier(db.Model):
+    """Supplier model representing a supplier in the system.
+    
+    Attributes:
+        id: Primary key, auto-incremented
+        name: Supplier's name (required, unique)
+        created_at: Timestamp when the record was created
+        updated_at: Timestamp when the record was last updated
+    """
+    __tablename__ = 'suppliers'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    name = db.Column(db.String(100), nullable=False, unique=True, index=True)
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
+    
+    def to_dict(self):
+        """Convert model to dictionary for JSON serialization.
+        
+        Returns:
+            Dictionary representation of the supplier
+        """
+        return {
+            'id': self.id,
+            'name': self.name,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+    
+    def __repr__(self):
+        return f'<Supplier {self.id}: {self.name}>'

+ 5 - 0
backend/app/models/work_record.py

@@ -12,6 +12,7 @@ class WorkRecord(db.Model):
         item_id: Foreign key to Item
         work_date: Date of the work
         quantity: Number of items produced (positive integer)
+        is_settled: Settlement status (default False)
         created_at: Timestamp when the record was created
         updated_at: Timestamp when the record was last updated
     """
@@ -22,6 +23,7 @@ class WorkRecord(db.Model):
     item_id = db.Column(db.Integer, db.ForeignKey('items.id'), nullable=False)
     work_date = db.Column(db.Date, nullable=False)
     quantity = db.Column(db.Integer, nullable=False)
+    is_settled = db.Column(db.Boolean, nullable=False, default=False)
     created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
     updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
     
@@ -53,9 +55,12 @@ class WorkRecord(db.Model):
             'item_id': self.item_id,
             'item_name': self.item.name if self.item else None,
             'unit_price': self.item.unit_price if self.item else None,
+            'supplier_id': self.item.supplier_id if self.item else None,
+            'supplier_name': self.item.supplier.name if self.item and self.item.supplier else '',
             'work_date': self.work_date.isoformat() if self.work_date else None,
             'quantity': self.quantity,
             'total_price': self.total_price,
+            'is_settled': self.is_settled,
             'created_at': self.created_at.isoformat() if self.created_at else None,
             'updated_at': self.updated_at.isoformat() if self.updated_at else None
         }

+ 2 - 0
backend/app/routes/__init__.py

@@ -25,6 +25,7 @@ def register_routes(app):
     from app.routes.work_record import work_record_ns
     from app.routes.export import export_ns
     from app.routes.import_routes import import_ns
+    from app.routes.supplier import supplier_ns
     
     api.add_namespace(auth_ns, path='/api/auth')
     api.add_namespace(admin_ns, path='/api/admins')
@@ -33,3 +34,4 @@ def register_routes(app):
     api.add_namespace(work_record_ns, path='/api/work-records')
     api.add_namespace(export_ns, path='/api/export')
     api.add_namespace(import_ns, path='/api/import')
+    api.add_namespace(supplier_ns, path='/api/suppliers')

+ 17 - 7
backend/app/routes/item.py

@@ -10,19 +10,23 @@ item_model = item_ns.model('Item', {
     'id': fields.Integer(readonly=True, description='物品ID'),
     'name': fields.String(required=True, description='物品名称'),
     'unit_price': fields.Float(required=True, description='单价'),
+    'supplier_id': fields.Integer(description='供应商ID'),
+    'supplier_name': fields.String(readonly=True, description='供应商名称'),
     'created_at': fields.String(readonly=True, description='创建时间'),
     'updated_at': fields.String(readonly=True, description='更新时间')
 })
 
 item_input = item_ns.model('ItemInput', {
     'name': fields.String(required=True, description='物品名称'),
-    'unit_price': fields.Float(required=True, description='单价')
+    'unit_price': fields.Float(required=True, description='单价'),
+    'supplier_id': fields.Integer(description='供应商ID(可选)')
 })
 
 item_update = item_ns.model('ItemUpdate', {
     'id': fields.Integer(required=True, description='物品ID'),
     'name': fields.String(description='物品名称'),
-    'unit_price': fields.Float(description='单价')
+    'unit_price': fields.Float(description='单价'),
+    'supplier_id': fields.Integer(description='供应商ID(可选,-1表示清除)')
 })
 
 item_delete = item_ns.model('ItemDelete', {
@@ -100,13 +104,16 @@ class ItemCreate(Resource):
         data = item_ns.payload
         name = data.get('name', '')
         unit_price = data.get('unit_price')
+        supplier_id = data.get('supplier_id')
         
-        item, error = ItemService.create(name, unit_price)
+        item, error = ItemService.create(name, unit_price, supplier_id)
         if error:
+            # Determine error code based on error message
+            error_code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
             return {
                 'success': False,
                 'error': error,
-                'code': 'VALIDATION_ERROR'
+                'code': error_code
             }, 400
         
         return {
@@ -132,6 +139,7 @@ class ItemUpdate(Resource):
         item_id = data.get('id')
         name = data.get('name')
         unit_price = data.get('unit_price')
+        supplier_id = data.get('supplier_id')
         
         if not item_id:
             return {
@@ -140,18 +148,20 @@ class ItemUpdate(Resource):
                 'code': 'VALIDATION_ERROR'
             }, 400
         
-        item, error = ItemService.update(item_id, name, unit_price)
+        item, error = ItemService.update(item_id, name, unit_price, supplier_id)
         if error:
-            if 'not found' in error.lower():
+            if '未找到' in error:
                 return {
                     'success': False,
                     'error': error,
                     'code': 'NOT_FOUND'
                 }, 404
+            # Determine error code based on error message
+            error_code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
             return {
                 'success': False,
                 'error': error,
-                'code': 'VALIDATION_ERROR'
+                'code': error_code
             }, 400
         
         return {

+ 5 - 3
backend/app/routes/person.py

@@ -99,10 +99,11 @@ class PersonCreate(Resource):
         
         person, error = PersonService.create(name)
         if error:
+            code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
             return {
                 'success': False,
                 'error': error,
-                'code': 'VALIDATION_ERROR'
+                'code': code
             }, 400
         
         return {
@@ -137,16 +138,17 @@ class PersonUpdate(Resource):
         
         person, error = PersonService.update(person_id, name)
         if error:
-            if 'not found' in error.lower():
+            if '未找到' in error:
                 return {
                     'success': False,
                     'error': error,
                     'code': 'NOT_FOUND'
                 }, 404
+            code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
             return {
                 'success': False,
                 'error': error,
-                'code': 'VALIDATION_ERROR'
+                'code': code
             }, 400
         
         return {

+ 201 - 0
backend/app/routes/supplier.py

@@ -0,0 +1,201 @@
+"""Supplier API routes."""
+from flask_restx import Namespace, Resource, fields
+from app.services.supplier_service import SupplierService
+from app.utils.auth_decorator import require_auth
+
+supplier_ns = Namespace('suppliers', description='供应商管理接口')
+
+# API models for Swagger documentation
+supplier_model = supplier_ns.model('Supplier', {
+    'id': fields.Integer(readonly=True, description='供应商ID'),
+    'name': fields.String(required=True, description='供应商名称'),
+    'created_at': fields.String(readonly=True, description='创建时间'),
+    'updated_at': fields.String(readonly=True, description='更新时间')
+})
+
+supplier_input = supplier_ns.model('SupplierInput', {
+    'name': fields.String(required=True, description='供应商名称')
+})
+
+supplier_update = supplier_ns.model('SupplierUpdate', {
+    'id': fields.Integer(required=True, description='供应商ID'),
+    'name': fields.String(description='供应商名称')
+})
+
+supplier_delete = supplier_ns.model('SupplierDelete', {
+    'id': fields.Integer(required=True, description='供应商ID')
+})
+
+# Response models
+success_response = supplier_ns.model('SuccessResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'data': fields.Raw(description='返回数据'),
+    'message': fields.String(description='消息')
+})
+
+error_response = supplier_ns.model('ErrorResponse', {
+    'success': fields.Boolean(description='操作是否成功'),
+    'error': fields.String(description='错误信息'),
+    'code': fields.String(description='错误代码')
+})
+
+
+@supplier_ns.route('')
+class SupplierList(Resource):
+    """Resource for listing all suppliers."""
+    
+    @supplier_ns.doc('list_suppliers')
+    @supplier_ns.response(200, 'Success', success_response)
+    @require_auth
+    def get(self):
+        """获取所有供应商列表"""
+        suppliers = SupplierService.get_all()
+        return {
+            'success': True,
+            'data': suppliers,
+            'message': 'Suppliers retrieved successfully'
+        }, 200
+
+
+@supplier_ns.route('/<int:id>')
+@supplier_ns.param('id', '供应商ID')
+class SupplierDetail(Resource):
+    """Resource for getting a single supplier."""
+    
+    @supplier_ns.doc('get_supplier')
+    @supplier_ns.response(200, 'Success', success_response)
+    @supplier_ns.response(404, 'Supplier not found', error_response)
+    @require_auth
+    def get(self, id):
+        """根据ID获取供应商信息"""
+        supplier, error = SupplierService.get_by_id(id)
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': supplier,
+            'message': 'Supplier retrieved successfully'
+        }, 200
+
+
+@supplier_ns.route('/create')
+class SupplierCreate(Resource):
+    """Resource for creating a supplier."""
+    
+    @supplier_ns.doc('create_supplier')
+    @supplier_ns.expect(supplier_input)
+    @supplier_ns.response(200, 'Success', success_response)
+    @supplier_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def post(self):
+        """创建新供应商"""
+        data = supplier_ns.payload
+        name = data.get('name', '')
+        
+        supplier, error = SupplierService.create(name)
+        if error:
+            code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
+            return {
+                'success': False,
+                'error': error,
+                'code': code
+            }, 400
+        
+        return {
+            'success': True,
+            'data': supplier,
+            'message': 'Supplier created successfully'
+        }, 200
+
+
+@supplier_ns.route('/update')
+class SupplierUpdate(Resource):
+    """Resource for updating a supplier."""
+    
+    @supplier_ns.doc('update_supplier')
+    @supplier_ns.expect(supplier_update)
+    @supplier_ns.response(200, 'Success', success_response)
+    @supplier_ns.response(400, 'Validation error', error_response)
+    @supplier_ns.response(404, 'Supplier not found', error_response)
+    @require_auth
+    def post(self):
+        """更新供应商信息"""
+        data = supplier_ns.payload
+        supplier_id = data.get('id')
+        name = data.get('name')
+        
+        if not supplier_id:
+            return {
+                'success': False,
+                'error': 'Supplier ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        supplier, error = SupplierService.update(supplier_id, name)
+        if error:
+            if '未找到' in error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            code = 'DUPLICATE_NAME' if '已存在' in error else 'VALIDATION_ERROR'
+            return {
+                'success': False,
+                'error': error,
+                'code': code
+            }, 400
+        
+        return {
+            'success': True,
+            'data': supplier,
+            'message': 'Supplier updated successfully'
+        }, 200
+
+
+@supplier_ns.route('/delete')
+class SupplierDelete(Resource):
+    """Resource for deleting a supplier."""
+    
+    @supplier_ns.doc('delete_supplier')
+    @supplier_ns.expect(supplier_delete)
+    @supplier_ns.response(200, 'Success', success_response)
+    @supplier_ns.response(400, 'Cannot delete', error_response)
+    @supplier_ns.response(404, 'Supplier not found', error_response)
+    @require_auth
+    def post(self):
+        """删除供应商"""
+        data = supplier_ns.payload
+        supplier_id = data.get('id')
+        
+        if not supplier_id:
+            return {
+                'success': False,
+                'error': 'Supplier ID is required',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        success, error = SupplierService.delete(supplier_id)
+        if error:
+            if '未找到' in error:
+                return {
+                    'success': False,
+                    'error': error,
+                    'code': 'NOT_FOUND'
+                }, 404
+            return {
+                'success': False,
+                'error': error,
+                'code': 'FOREIGN_KEY_VIOLATION'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': None,
+            'message': 'Supplier deleted successfully'
+        }, 200

+ 143 - 4
backend/app/routes/work_record.py

@@ -14,9 +14,12 @@ work_record_model = work_record_ns.model('WorkRecord', {
     'item_id': fields.Integer(required=True, description='物品ID'),
     'item_name': fields.String(readonly=True, description='物品名称'),
     'unit_price': fields.Float(readonly=True, description='单价'),
+    'supplier_id': fields.Integer(readonly=True, description='供应商ID'),
+    'supplier_name': fields.String(readonly=True, description='供应商名称'),
     'work_date': fields.String(required=True, description='工作日期 (YYYY-MM-DD)'),
     'quantity': fields.Integer(required=True, description='数量'),
     'total_price': fields.Float(readonly=True, description='总价'),
+    'is_settled': fields.Boolean(readonly=True, description='结算状态'),
     'created_at': fields.String(readonly=True, description='创建时间'),
     'updated_at': fields.String(readonly=True, description='更新时间')
 })
@@ -40,6 +43,20 @@ work_record_delete = work_record_ns.model('WorkRecordDelete', {
     'id': fields.Integer(required=True, description='记录ID')
 })
 
+# Settlement models
+batch_settlement_input = work_record_ns.model('BatchSettlementInput', {
+    'person_id': fields.Integer(description='人员ID(可选)'),
+    'year': fields.Integer(required=True, description='年份'),
+    'month': fields.Integer(required=True, description='月份 (1-12)'),
+    'supplier_id': fields.Integer(description='供应商ID(可选)'),
+    'is_settled': fields.Boolean(required=True, description='结算状态')
+})
+
+batch_settlement_response = work_record_ns.model('BatchSettlementResponse', {
+    'updated_count': fields.Integer(description='更新的记录数'),
+    'total_matched': fields.Integer(description='匹配的总记录数')
+})
+
 # Response models
 success_response = work_record_ns.model('SuccessResponse', {
     'success': fields.Boolean(description='操作是否成功'),
@@ -56,6 +73,7 @@ error_response = work_record_ns.model('ErrorResponse', {
 # Daily summary models
 daily_summary_item = work_record_ns.model('DailySummaryItem', {
     'item_name': fields.String(description='物品名称'),
+    'supplier_name': fields.String(description='供应商名称'),
     'unit_price': fields.Float(description='单价'),
     'quantity': fields.Integer(description='数量'),
     'total_price': fields.Float(description='总价')
@@ -86,17 +104,26 @@ monthly_top_performer = work_record_ns.model('MonthlyTopPerformer', {
 monthly_item_breakdown = work_record_ns.model('MonthlyItemBreakdown', {
     'item_id': fields.Integer(description='物品ID'),
     'item_name': fields.String(description='物品名称'),
+    'supplier_name': fields.String(description='供应商名称'),
     'quantity': fields.Integer(description='数量'),
     'earnings': fields.Float(description='收入')
 })
 
+monthly_supplier_breakdown = work_record_ns.model('MonthlySupplierBreakdown', {
+    'person_id': fields.Integer(description='人员ID'),
+    'person_name': fields.String(description='人员姓名'),
+    'supplier_name': fields.String(description='供应商名称'),
+    'earnings': fields.Float(description='收入')
+})
+
 monthly_summary_response = work_record_ns.model('MonthlySummaryResponse', {
     'year': fields.Integer(description='年份'),
     'month': fields.Integer(description='月份'),
     'total_records': fields.Integer(description='总记录数'),
     'total_earnings': fields.Float(description='总收入'),
     'top_performers': fields.List(fields.Nested(monthly_top_performer), description='业绩排名'),
-    'item_breakdown': fields.List(fields.Nested(monthly_item_breakdown), description='物品收入明细')
+    'item_breakdown': fields.List(fields.Nested(monthly_item_breakdown), description='物品收入明细'),
+    'supplier_breakdown': fields.List(fields.Nested(monthly_supplier_breakdown), description='人员按供应商收入明细')
 })
 
 # Yearly summary models
@@ -104,14 +131,18 @@ 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_total': fields.Float(description='年度总收入'),
+    'settled_total': fields.Float(description='已结算总收入'),
+    'unsettled_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='年度总计')
+    'grand_total': fields.Float(description='年度总计'),
+    'settled_grand_total': fields.Float(description='已结算年度总计'),
+    'unsettled_grand_total': fields.Float(description='未结算年度总计')
 })
 
 
@@ -126,6 +157,7 @@ class WorkRecordList(Resource):
     @work_record_ns.param('month', '按月份筛选 (1-12)', type=int)
     @work_record_ns.param('start_date', '开始日期 (YYYY-MM-DD)', type=str)
     @work_record_ns.param('end_date', '结束日期 (YYYY-MM-DD)', type=str)
+    @work_record_ns.param('is_settled', '按结算状态筛选 (true/false)', type=str)
     @work_record_ns.response(200, 'Success', success_response)
     @require_auth
     def get(self):
@@ -137,6 +169,12 @@ class WorkRecordList(Resource):
         start_date = request.args.get('start_date')
         end_date = request.args.get('end_date')
         
+        # Parse is_settled parameter
+        is_settled_param = request.args.get('is_settled')
+        is_settled = None
+        if is_settled_param is not None:
+            is_settled = is_settled_param.lower() == 'true'
+        
         # 如果指定了具体日期,使用 start_date 和 end_date 来筛选同一天
         if date:
             start_date = date
@@ -147,7 +185,8 @@ class WorkRecordList(Resource):
             start_date=start_date,
             end_date=end_date,
             year=year,
-            month=month
+            month=month,
+            is_settled=is_settled
         )
         return {
             'success': True,
@@ -441,3 +480,103 @@ class WorkRecordYearlySummary(Resource):
                 'error': str(e),
                 'code': 'INTERNAL_ERROR'
             }, 500
+
+
+@work_record_ns.route('/<int:id>/settlement')
+@work_record_ns.param('id', '记录ID')
+class WorkRecordSettlement(Resource):
+    """Resource for toggling settlement status of a single work record."""
+    
+    @work_record_ns.doc('toggle_settlement')
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(404, 'Work record not found', error_response)
+    @require_auth
+    def put(self, id):
+        """切换单条工作记录的结算状态"""
+        work_record, error = WorkRecordService.toggle_settlement(id)
+        
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'NOT_FOUND'
+            }, 404
+        
+        return {
+            'success': True,
+            'data': work_record,
+            'message': 'Settlement status toggled successfully'
+        }, 200
+
+
+@work_record_ns.route('/batch-settlement')
+class WorkRecordBatchSettlement(Resource):
+    """Resource for batch updating settlement status."""
+    
+    @work_record_ns.doc('batch_settlement')
+    @work_record_ns.expect(batch_settlement_input)
+    @work_record_ns.response(200, 'Success', success_response)
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def post(self):
+        """批量更新工作记录的结算状态"""
+        data = work_record_ns.payload
+        
+        year = data.get('year')
+        month = data.get('month')
+        is_settled = data.get('is_settled')
+        person_id = data.get('person_id')
+        supplier_id = data.get('supplier_id')
+        
+        # Handle "none" string for items without supplier
+        if supplier_id == 'none':
+            pass  # Keep as string "none"
+        elif supplier_id is not None:
+            # Convert to int if it's a valid number
+            try:
+                supplier_id = int(supplier_id)
+            except (ValueError, TypeError):
+                supplier_id = None
+        
+        # Validate required fields
+        if year is None or month is None:
+            return {
+                'success': False,
+                'error': '年份和月份为必填参数',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        if is_settled is None:
+            return {
+                'success': False,
+                'error': '结算状态为必填参数',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        if month < 1 or month > 12:
+            return {
+                'success': False,
+                'error': '月份必须在 1 到 12 之间',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        result, error = WorkRecordService.batch_update_settlement(
+            year=year,
+            month=month,
+            is_settled=is_settled,
+            person_id=person_id,
+            supplier_id=supplier_id
+        )
+        
+        if error:
+            return {
+                'success': False,
+                'error': error,
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        return {
+            'success': True,
+            'data': result,
+            'message': f'成功更新 {result["updated_count"]} 条记录的结算状态'
+        }, 200

+ 141 - 19
backend/app/services/export_service.py

@@ -3,7 +3,7 @@ from datetime import date
 from io import BytesIO
 from calendar import monthrange
 from openpyxl import Workbook
-from openpyxl.styles import Font, Alignment, Border, Side
+from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
 from openpyxl.utils import get_column_letter
 from app import db
 from app.models.work_record import WorkRecord
@@ -14,7 +14,10 @@ class ExportService:
     """Service class for Excel export operations."""
     
     # Column headers for detail sheet
-    DETAIL_HEADERS = ['人员', '日期', '物品', '单价', '数量', '总价']
+    DETAIL_HEADERS = ['人员', '日期', '供应商', '物品', '单价', '数量', '总价', '结算状态']
+    
+    # Style for unsettled status highlighting (light orange #FFF2E8)
+    UNSETTLED_FILL = PatternFill(start_color='FFF2E8', end_color='FFF2E8', fill_type='solid')
     
     @staticmethod
     def _apply_header_style(cell):
@@ -100,25 +103,36 @@ class ExportService:
         
         # Write data rows
         for row_idx, record in enumerate(records, 2):
+            # Get supplier name (empty string if no supplier)
+            supplier_name = record.item.supplier.name if record.item and record.item.supplier else ''
+            
+            # Get settlement status text
+            settlement_status = '已结算' if record.is_settled else '未结算'
+            
             data = [
                 record.person.name,
                 record.work_date.strftime('%Y-%m-%d'),
+                supplier_name,
                 record.item.name,
                 record.item.unit_price,
                 record.quantity,
-                record.total_price
+                record.total_price,
+                settlement_status
             ]
             for col_idx, value in enumerate(data, 1):
                 cell = ws.cell(row=row_idx, column=col_idx, value=value)
-                is_number = col_idx in [4, 5, 6]  # unit_price, quantity, total_price
+                is_number = col_idx in [5, 6, 7]  # unit_price, quantity, total_price
                 ExportService._apply_cell_style(cell, is_number)
+                # Apply unsettled fill to entire row if record is unsettled
+                if not record.is_settled:
+                    cell.fill = ExportService.UNSETTLED_FILL
         
         ExportService._auto_adjust_column_width(ws)
         return ws
     
     @staticmethod
     def _create_monthly_summary_sheet(workbook, records):
-        """Create monthly summary sheet grouped by person.
+        """Create monthly summary sheet grouped by person, supplier, and settlement status.
         
         Args:
             workbook: openpyxl Workbook
@@ -130,31 +144,57 @@ class ExportService:
         ws = workbook.create_sheet(title='月度汇总')
         
         # Write headers
-        headers = ['人员', '总金额']
+        headers = ['人员', '供应商', '总金额', '结算状态']
         for col, header in enumerate(headers, 1):
             cell = ws.cell(row=1, column=col, value=header)
             ExportService._apply_header_style(cell)
         
-        # Calculate totals by person
-        person_totals = {}
+        # Calculate totals by person, supplier, and settlement status
+        person_supplier_settlement_totals = {}
         for record in records:
             person_name = record.person.name
-            if person_name not in person_totals:
-                person_totals[person_name] = 0.0
-            person_totals[person_name] += record.total_price
+            supplier_name = record.item.supplier.name if record.item and record.item.supplier else ''
+            is_settled = record.is_settled
+            key = (person_name, supplier_name, is_settled)
+            
+            if key not in person_supplier_settlement_totals:
+                person_supplier_settlement_totals[key] = 0.0
+            person_supplier_settlement_totals[key] += record.total_price
         
-        # Write data rows
+        # Write data rows (sorted by person name, then supplier name, then settlement status)
         row_idx = 2
         grand_total = 0.0
-        for person_name in sorted(person_totals.keys()):
-            total = person_totals[person_name]
+        settled_total = 0.0
+        unsettled_total = 0.0
+        for (person_name, supplier_name, is_settled) in sorted(person_supplier_settlement_totals.keys()):
+            total = person_supplier_settlement_totals[(person_name, supplier_name, is_settled)]
             grand_total += total
+            if is_settled:
+                settled_total += total
+            else:
+                unsettled_total += total
+            
+            settlement_status = '已结算' if is_settled else '未结算'
             
             cell = ws.cell(row=row_idx, column=1, value=person_name)
             ExportService._apply_cell_style(cell)
+            if not is_settled:
+                cell.fill = ExportService.UNSETTLED_FILL
             
-            cell = ws.cell(row=row_idx, column=2, value=round(total, 2))
+            cell = ws.cell(row=row_idx, column=2, value=supplier_name)
+            ExportService._apply_cell_style(cell)
+            if not is_settled:
+                cell.fill = ExportService.UNSETTLED_FILL
+            
+            cell = ws.cell(row=row_idx, column=3, value=round(total, 2))
             ExportService._apply_cell_style(cell, is_number=True)
+            if not is_settled:
+                cell.fill = ExportService.UNSETTLED_FILL
+            
+            cell = ws.cell(row=row_idx, column=4, value=settlement_status)
+            ExportService._apply_cell_style(cell)
+            if not is_settled:
+                cell.fill = ExportService.UNSETTLED_FILL
             
             row_idx += 1
         
@@ -163,10 +203,54 @@ class ExportService:
         cell.font = Font(bold=True)
         ExportService._apply_cell_style(cell)
         
-        cell = ws.cell(row=row_idx, column=2, value=round(grand_total, 2))
+        cell = ws.cell(row=row_idx, column=2, value='')
+        ExportService._apply_cell_style(cell)
+        
+        cell = ws.cell(row=row_idx, column=3, value=round(grand_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        
+        cell = ws.cell(row=row_idx, column=4, value='')
+        ExportService._apply_cell_style(cell)
+        
+        row_idx += 1
+        
+        # Write settled total row
+        cell = ws.cell(row=row_idx, column=1, value='已结算')
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell)
+        
+        cell = ws.cell(row=row_idx, column=2, value='')
+        ExportService._apply_cell_style(cell)
+        
+        cell = ws.cell(row=row_idx, column=3, value=round(settled_total, 2))
         cell.font = Font(bold=True)
         ExportService._apply_cell_style(cell, is_number=True)
         
+        cell = ws.cell(row=row_idx, column=4, value='')
+        ExportService._apply_cell_style(cell)
+        
+        row_idx += 1
+        
+        # Write unsettled total row
+        cell = ws.cell(row=row_idx, column=1, value='未结算')
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell)
+        cell.fill = ExportService.UNSETTLED_FILL
+        
+        cell = ws.cell(row=row_idx, column=2, value='')
+        ExportService._apply_cell_style(cell)
+        cell.fill = ExportService.UNSETTLED_FILL
+        
+        cell = ws.cell(row=row_idx, column=3, value=round(unsettled_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        cell.fill = ExportService.UNSETTLED_FILL
+        
+        cell = ws.cell(row=row_idx, column=4, value='')
+        ExportService._apply_cell_style(cell)
+        cell.fill = ExportService.UNSETTLED_FILL
+        
         ExportService._auto_adjust_column_width(ws)
         return ws
 
@@ -184,29 +268,43 @@ class ExportService:
         """
         ws = workbook.create_sheet(title='年度汇总')
         
-        # Write headers: 人员, 1月, 2月, ..., 12月, 年度合计
-        headers = ['人员'] + [f'{m}月' for m in range(1, 13)] + ['年度合计']
+        # Write headers: 人员, 1月, 2月, ..., 12月, 年度合计, 已结算, 未结算
+        headers = ['人员'] + [f'{m}月' for m in range(1, 13)] + ['年度合计', '已结算', '未结算']
         for col, header in enumerate(headers, 1):
             cell = ws.cell(row=1, column=col, value=header)
             ExportService._apply_header_style(cell)
+            # Apply unsettled fill to the "未结算" column header (column 16)
+            if col == 16:
+                cell.fill = ExportService.UNSETTLED_FILL
         
-        # Calculate totals by person and month
+        # Calculate totals by person and month, plus settlement status
         person_monthly_totals = {}
+        person_settlement_totals = {}
         for record in records:
             person_name = record.person.name
             month = record.work_date.month
             
             if person_name not in person_monthly_totals:
                 person_monthly_totals[person_name] = {m: 0.0 for m in range(1, 13)}
+                person_settlement_totals[person_name] = {'settled': 0.0, 'unsettled': 0.0}
             
             person_monthly_totals[person_name][month] += record.total_price
+            
+            # Track settlement status
+            if record.is_settled:
+                person_settlement_totals[person_name]['settled'] += record.total_price
+            else:
+                person_settlement_totals[person_name]['unsettled'] += record.total_price
         
         # Write data rows
         row_idx = 2
         monthly_grand_totals = {m: 0.0 for m in range(1, 13)}
+        settled_grand_total = 0.0
+        unsettled_grand_total = 0.0
         
         for person_name in sorted(person_monthly_totals.keys()):
             monthly_totals = person_monthly_totals[person_name]
+            settlement_totals = person_settlement_totals[person_name]
             yearly_total = sum(monthly_totals.values())
             
             # Person name
@@ -224,6 +322,19 @@ class ExportService:
             cell = ws.cell(row=row_idx, column=14, value=round(yearly_total, 2))
             ExportService._apply_cell_style(cell, is_number=True)
             
+            # Settled total
+            settled = settlement_totals['settled']
+            settled_grand_total += settled
+            cell = ws.cell(row=row_idx, column=15, value=round(settled, 2))
+            ExportService._apply_cell_style(cell, is_number=True)
+            
+            # Unsettled total
+            unsettled = settlement_totals['unsettled']
+            unsettled_grand_total += unsettled
+            cell = ws.cell(row=row_idx, column=16, value=round(unsettled, 2))
+            ExportService._apply_cell_style(cell, is_number=True)
+            cell.fill = ExportService.UNSETTLED_FILL
+            
             row_idx += 1
         
         # Write grand total row
@@ -243,6 +354,17 @@ class ExportService:
         cell.font = Font(bold=True)
         ExportService._apply_cell_style(cell, is_number=True)
         
+        # Settled grand total
+        cell = ws.cell(row=row_idx, column=15, value=round(settled_grand_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        
+        # Unsettled grand total
+        cell = ws.cell(row=row_idx, column=16, value=round(unsettled_grand_total, 2))
+        cell.font = Font(bold=True)
+        ExportService._apply_cell_style(cell, is_number=True)
+        cell.fill = ExportService.UNSETTLED_FILL
+        
         ExportService._auto_adjust_column_width(ws)
         return ws
 

+ 53 - 11
backend/app/services/item_service.py

@@ -1,6 +1,8 @@
 """Item service for business logic operations."""
+from sqlalchemy.exc import IntegrityError
 from app import db
 from app.models.item import Item
+from app.models.supplier import Supplier
 from app.utils.validators import is_valid_name, is_positive_number
 
 
@@ -8,12 +10,13 @@ class ItemService:
     """Service class for Item CRUD operations."""
     
     @staticmethod
-    def create(name, unit_price):
+    def create(name, unit_price, supplier_id=None):
         """Create a new item.
         
         Args:
             name: Item's name
             unit_price: Price per unit
+            supplier_id: Optional supplier ID
             
         Returns:
             Tuple of (item_dict, error_message)
@@ -26,11 +29,25 @@ class ItemService:
         if not is_positive_number(unit_price):
             return None, "单价必须为正数"
         
-        item = Item(name=name.strip(), unit_price=float(unit_price))
-        db.session.add(item)
-        db.session.commit()
-        
-        return item.to_dict(), None
+        # Check for duplicate name
+        existing = Item.query.filter(Item.name == name.strip()).first()
+        if existing:
+            return None, "物品名称已存在"
+        
+        # Validate supplier_id if provided
+        if supplier_id is not None:
+            supplier = db.session.get(Supplier, supplier_id)
+            if not supplier:
+                return None, f"未找到ID为 {supplier_id} 的供应商"
+        
+        try:
+            item = Item(name=name.strip(), unit_price=float(unit_price), supplier_id=supplier_id)
+            db.session.add(item)
+            db.session.commit()
+            return item.to_dict(), None
+        except IntegrityError:
+            db.session.rollback()
+            return None, "物品名称已存在"
     
     @staticmethod
     def get_all():
@@ -61,13 +78,14 @@ class ItemService:
         return item.to_dict(), None
     
     @staticmethod
-    def update(item_id, name=None, unit_price=None):
-        """Update an item's name and/or unit_price.
+    def update(item_id, name=None, unit_price=None, supplier_id=None):
+        """Update an item's name, unit_price, and/or supplier_id.
         
         Args:
             item_id: Item's ID
             name: New name (optional)
             unit_price: New unit price (optional)
+            supplier_id: New supplier ID (optional, use -1 to clear)
             
         Returns:
             Tuple of (item_dict, error_message)
@@ -81,6 +99,15 @@ class ItemService:
         if name is not None:
             if not is_valid_name(name):
                 return None, "物品名称不能为空"
+            
+            # Check for duplicate name (excluding current item)
+            existing = Item.query.filter(
+                Item.name == name.strip(),
+                Item.id != item_id
+            ).first()
+            if existing:
+                return None, "物品名称已存在"
+            
             item.name = name.strip()
         
         if unit_price is not None:
@@ -88,9 +115,24 @@ class ItemService:
                 return None, "单价必须为正数"
             item.unit_price = float(unit_price)
         
-        db.session.commit()
-        
-        return item.to_dict(), None
+        # Handle supplier_id update
+        if supplier_id is not None:
+            if supplier_id == -1 or supplier_id == '':
+                # Clear supplier
+                item.supplier_id = None
+            else:
+                # Validate supplier exists
+                supplier = db.session.get(Supplier, supplier_id)
+                if not supplier:
+                    return None, f"未找到ID为 {supplier_id} 的供应商"
+                item.supplier_id = supplier_id
+        
+        try:
+            db.session.commit()
+            return item.to_dict(), None
+        except IntegrityError:
+            db.session.rollback()
+            return None, "物品名称已存在"
     
     @staticmethod
     def delete(item_id):

+ 27 - 7
backend/app/services/person_service.py

@@ -1,4 +1,5 @@
 """Person service for business logic operations."""
+from sqlalchemy.exc import IntegrityError
 from app import db
 from app.models.person import Person
 from app.utils.validators import is_valid_name
@@ -22,11 +23,19 @@ class PersonService:
         if not is_valid_name(name):
             return None, "人员名称不能为空"
         
-        person = Person(name=name.strip())
-        db.session.add(person)
-        db.session.commit()
+        # Check for duplicate name
+        existing = Person.query.filter(Person.name == name.strip()).first()
+        if existing:
+            return None, "人员名称已存在"
         
-        return person.to_dict(), None
+        try:
+            person = Person(name=name.strip())
+            db.session.add(person)
+            db.session.commit()
+            return person.to_dict(), None
+        except IntegrityError:
+            db.session.rollback()
+            return None, "人员名称已存在"
     
     @staticmethod
     def get_all():
@@ -76,10 +85,21 @@ class PersonService:
         if not person:
             return None, f"未找到ID为 {person_id} 的人员"
         
-        person.name = name.strip()
-        db.session.commit()
+        # Check for duplicate name (excluding current person)
+        existing = Person.query.filter(
+            Person.name == name.strip(),
+            Person.id != person_id
+        ).first()
+        if existing:
+            return None, "人员名称已存在"
         
-        return person.to_dict(), None
+        try:
+            person.name = name.strip()
+            db.session.commit()
+            return person.to_dict(), None
+        except IntegrityError:
+            db.session.rollback()
+            return None, "人员名称已存在"
     
     @staticmethod
     def delete(person_id):

+ 129 - 0
backend/app/services/supplier_service.py

@@ -0,0 +1,129 @@
+"""Supplier service for business logic operations."""
+from sqlalchemy.exc import IntegrityError
+from app import db
+from app.models.supplier import Supplier
+from app.utils.validators import is_valid_name
+
+
+class SupplierService:
+    """Service class for Supplier CRUD operations."""
+    
+    @staticmethod
+    def create(name):
+        """Create a new supplier.
+        
+        Args:
+            name: Supplier's name
+            
+        Returns:
+            Tuple of (supplier_dict, error_message)
+            On success: (supplier_dict, None)
+            On failure: (None, error_message)
+        """
+        if not is_valid_name(name):
+            return None, "供应商名称不能为空"
+        
+        # Check for duplicate name
+        existing = Supplier.query.filter(Supplier.name == name.strip()).first()
+        if existing:
+            return None, "供应商名称已存在"
+        
+        try:
+            supplier = Supplier(name=name.strip())
+            db.session.add(supplier)
+            db.session.commit()
+            return supplier.to_dict(), None
+        except IntegrityError:
+            db.session.rollback()
+            return None, "供应商名称已存在"
+    
+    @staticmethod
+    def get_all():
+        """Get all suppliers.
+        
+        Returns:
+            List of supplier dictionaries
+        """
+        suppliers = Supplier.query.order_by(Supplier.id).all()
+        return [s.to_dict() for s in suppliers]
+    
+    @staticmethod
+    def get_by_id(supplier_id):
+        """Get a supplier by ID.
+        
+        Args:
+            supplier_id: Supplier's ID
+            
+        Returns:
+            Tuple of (supplier_dict, error_message)
+            On success: (supplier_dict, None)
+            On failure: (None, error_message)
+        """
+        supplier = db.session.get(Supplier, supplier_id)
+        if not supplier:
+            return None, f"未找到ID为 {supplier_id} 的供应商"
+        
+        return supplier.to_dict(), None
+    
+    @staticmethod
+    def update(supplier_id, name=None):
+        """Update a supplier's name.
+        
+        Args:
+            supplier_id: Supplier's ID
+            name: New name (optional)
+            
+        Returns:
+            Tuple of (supplier_dict, error_message)
+            On success: (supplier_dict, None)
+            On failure: (None, error_message)
+        """
+        supplier = db.session.get(Supplier, supplier_id)
+        if not supplier:
+            return None, f"未找到ID为 {supplier_id} 的供应商"
+        
+        if name is not None:
+            if not is_valid_name(name):
+                return None, "供应商名称不能为空"
+            
+            # Check for duplicate name (excluding current supplier)
+            existing = Supplier.query.filter(
+                Supplier.name == name.strip(),
+                Supplier.id != supplier_id
+            ).first()
+            if existing:
+                return None, "供应商名称已存在"
+            
+            try:
+                supplier.name = name.strip()
+                db.session.commit()
+            except IntegrityError:
+                db.session.rollback()
+                return None, "供应商名称已存在"
+        
+        return supplier.to_dict(), None
+    
+    @staticmethod
+    def delete(supplier_id):
+        """Delete a supplier.
+        
+        Args:
+            supplier_id: Supplier's ID
+            
+        Returns:
+            Tuple of (success, error_message)
+            On success: (True, None)
+            On failure: (False, error_message)
+        """
+        supplier = db.session.get(Supplier, supplier_id)
+        if not supplier:
+            return False, f"未找到ID为 {supplier_id} 的供应商"
+        
+        # Check if supplier has associated items
+        if hasattr(supplier, 'items') and supplier.items.count() > 0:
+            return False, "无法删除已有关联物品的供应商"
+        
+        db.session.delete(supplier)
+        db.session.commit()
+        
+        return True, None

+ 175 - 20
backend/app/services/work_record_service.py

@@ -58,7 +58,7 @@ class WorkRecordService:
         return work_record.to_dict(), None
     
     @staticmethod
-    def get_all(person_id=None, start_date=None, end_date=None, year=None, month=None):
+    def get_all(person_id=None, start_date=None, end_date=None, year=None, month=None, is_settled=None):
         """Get all work records with optional filters.
         
         All filters are applied as intersection (AND logic).
@@ -69,6 +69,7 @@ class WorkRecordService:
             end_date: Filter by end date (optional)
             year: Filter by year (optional, used with month)
             month: Filter by month 1-12 (optional, used with year)
+            is_settled: Filter by settlement status (optional, True/False)
             
         Returns:
             List of work record dictionaries
@@ -78,6 +79,10 @@ class WorkRecordService:
         if person_id is not None:
             query = query.filter(WorkRecord.person_id == person_id)
         
+        # Apply settlement status filter
+        if is_settled is not None:
+            query = query.filter(WorkRecord.is_settled == is_settled)
+        
         # Apply month filter if both year and month are provided
         if year is not None and month is not None:
             month_start = date(year, month, 1)
@@ -199,7 +204,7 @@ class WorkRecordService:
             person_id: Filter by person ID (optional)
             
         Returns:
-            Dictionary with daily summary data
+            Dictionary with daily summary data grouped by person and supplier
         """
         if isinstance(work_date, str):
             work_date = datetime.fromisoformat(work_date).date()
@@ -211,33 +216,44 @@ class WorkRecordService:
         
         work_records = query.all()
         
-        # Group by person
-        summary_by_person = {}
+        # Group by person and supplier
+        summary_by_person_supplier = {}
         for wr in work_records:
             person_name = wr.person.name
-            if person_name not in summary_by_person:
-                summary_by_person[person_name] = {
+            supplier_name = wr.item.supplier.name if wr.item.supplier else ''
+            key = (wr.person_id, person_name, supplier_name)
+            
+            if key not in summary_by_person_supplier:
+                summary_by_person_supplier[key] = {
                     'person_id': wr.person_id,
                     'person_name': person_name,
+                    'supplier_name': supplier_name,
                     'total_items': 0,
                     'total_value': 0.0,
                     'items': []
                 }
             
-            summary_by_person[person_name]['total_items'] += wr.quantity
-            summary_by_person[person_name]['total_value'] += wr.total_price
-            summary_by_person[person_name]['items'].append({
+            summary_by_person_supplier[key]['total_items'] += wr.quantity
+            summary_by_person_supplier[key]['total_value'] += wr.total_price
+            summary_by_person_supplier[key]['items'].append({
                 'item_name': wr.item.name,
+                'supplier_name': supplier_name,
                 'unit_price': wr.item.unit_price,
                 'quantity': wr.quantity,
                 'total_price': wr.total_price
             })
         
+        # Sort by person_name, then supplier_name
+        summary_list = sorted(
+            summary_by_person_supplier.values(),
+            key=lambda x: (x['person_name'], x['supplier_name'])
+        )
+        
         return {
             'date': work_date.isoformat(),
-            'summary': list(summary_by_person.values()),
-            'grand_total_items': sum(p['total_items'] for p in summary_by_person.values()),
-            'grand_total_value': sum(p['total_value'] for p in summary_by_person.values())
+            'summary': summary_list,
+            'grand_total_items': sum(p['total_items'] for p in summary_list),
+            'grand_total_value': sum(p['total_value'] for p in summary_list)
         }
 
     @staticmethod
@@ -252,9 +268,12 @@ class WorkRecordService:
         Returns:
             Dictionary with yearly summary data including:
             - year: The year
-            - persons: List of person summaries with monthly_earnings and yearly_total
+            - persons: List of person summaries with monthly_earnings, yearly_total,
+                       settled_total, and unsettled_total
             - monthly_totals: Array of 12 monthly totals across all persons
             - grand_total: Overall yearly total
+            - settled_grand_total: Total settled earnings for the year
+            - unsettled_grand_total: Total unsettled earnings for the year
         """
         # Calculate date range for the year
         start_date = date(year, 1, 1)
@@ -267,6 +286,7 @@ class WorkRecordService:
         ).join(Person).order_by(Person.name).all()
         
         # Calculate totals by person and month (same logic as ExportService)
+        # Also track settlement status per person
         person_monthly_totals = {}
         for record in work_records:
             person_id = record.person_id
@@ -277,14 +297,24 @@ class WorkRecordService:
                 person_monthly_totals[person_id] = {
                     'person_id': person_id,
                     'person_name': person_name,
-                    'monthly_data': {m: 0.0 for m in range(1, 13)}
+                    'monthly_data': {m: 0.0 for m in range(1, 13)},
+                    'settled_total': 0.0,
+                    'unsettled_total': 0.0
                 }
             
             person_monthly_totals[person_id]['monthly_data'][month] += record.total_price
+            
+            # Track settlement status
+            if record.is_settled:
+                person_monthly_totals[person_id]['settled_total'] += record.total_price
+            else:
+                person_monthly_totals[person_id]['unsettled_total'] += record.total_price
         
         # Build persons list sorted alphabetically by name
         persons = []
         monthly_grand_totals = [0.0] * 12
+        settled_grand_total = 0.0
+        unsettled_grand_total = 0.0
         
         for person_data in sorted(person_monthly_totals.values(), key=lambda x: x['person_name']):
             monthly_earnings = []
@@ -296,12 +326,21 @@ class WorkRecordService:
                 yearly_total += value
                 monthly_grand_totals[month - 1] += value
             
+            settled_total = round(person_data['settled_total'], 2)
+            unsettled_total = round(person_data['unsettled_total'], 2)
+            
             persons.append({
                 'person_id': person_data['person_id'],
                 'person_name': person_data['person_name'],
                 'monthly_earnings': monthly_earnings,
-                'yearly_total': round(yearly_total, 2)
+                'yearly_total': round(yearly_total, 2),
+                'settled_total': settled_total,
+                'unsettled_total': unsettled_total
             })
+            
+            # Accumulate grand totals for settlement
+            settled_grand_total += settled_total
+            unsettled_grand_total += unsettled_total
         
         # Round monthly totals
         monthly_totals = [round(total, 2) for total in monthly_grand_totals]
@@ -311,7 +350,9 @@ class WorkRecordService:
             'year': year,
             'persons': persons,
             'monthly_totals': monthly_totals,
-            'grand_total': grand_total
+            'grand_total': grand_total,
+            'settled_grand_total': round(settled_grand_total, 2),
+            'unsettled_grand_total': round(unsettled_grand_total, 2)
         }
 
     @staticmethod
@@ -326,8 +367,11 @@ class WorkRecordService:
             Dictionary with monthly summary data including:
             - total_records: Total number of work records
             - total_earnings: Total earnings for the month
+            - settled_earnings: Total settled earnings for the month
+            - unsettled_earnings: Total unsettled earnings for the month
             - top_performers: List of persons ranked by earnings (descending)
-            - item_breakdown: List of items with quantity and earnings
+            - item_breakdown: List of items with quantity, earnings, and supplier_name
+            - supplier_breakdown: List of person-by-supplier earnings with is_settled field
         """
         # Calculate month date range
         month_start = date(year, month, 1)
@@ -342,9 +386,11 @@ class WorkRecordService:
             WorkRecord.work_date < month_end
         ).all()
         
-        # Calculate totals
+        # Calculate totals including settlement status
         total_records = len(work_records)
         total_earnings = sum(wr.total_price for wr in work_records)
+        settled_earnings = sum(wr.total_price for wr in work_records if wr.is_settled)
+        unsettled_earnings = sum(wr.total_price for wr in work_records if not wr.is_settled)
         
         # Group by person for top performers
         person_earnings = {}
@@ -365,14 +411,16 @@ class WorkRecordService:
             reverse=True
         )
         
-        # Group by item for breakdown
+        # Group by item for breakdown (with supplier_name)
         item_breakdown_dict = {}
         for wr in work_records:
             item_id = wr.item_id
             if item_id not in item_breakdown_dict:
+                supplier_name = wr.item.supplier.name if wr.item.supplier else ''
                 item_breakdown_dict[item_id] = {
                     'item_id': item_id,
                     'item_name': wr.item.name,
+                    'supplier_name': supplier_name,
                     'quantity': 0,
                     'earnings': 0.0
                 }
@@ -386,11 +434,118 @@ class WorkRecordService:
             reverse=True
         )
         
+        # Group by person, supplier, and settlement status for supplier_breakdown
+        person_supplier_earnings = {}
+        for wr in work_records:
+            person_id = wr.person_id
+            supplier_name = wr.item.supplier.name if wr.item.supplier else ''
+            is_settled = wr.is_settled
+            key = (person_id, supplier_name, is_settled)
+            
+            if key not in person_supplier_earnings:
+                person_supplier_earnings[key] = {
+                    'person_id': person_id,
+                    'person_name': wr.person.name,
+                    'supplier_name': supplier_name,
+                    'earnings': 0.0,
+                    'is_settled': is_settled
+                }
+            person_supplier_earnings[key]['earnings'] += wr.total_price
+        
+        # Sort by person_name, then supplier_name, then is_settled (settled first)
+        supplier_breakdown = sorted(
+            person_supplier_earnings.values(),
+            key=lambda x: (x['person_name'], x['supplier_name'], not x['is_settled'])
+        )
+        
         return {
             'year': year,
             'month': month,
             'total_records': total_records,
             'total_earnings': total_earnings,
+            'settled_earnings': settled_earnings,
+            'unsettled_earnings': unsettled_earnings,
             'top_performers': top_performers,
-            'item_breakdown': item_breakdown
+            'item_breakdown': item_breakdown,
+            'supplier_breakdown': supplier_breakdown
         }
+
+    @staticmethod
+    def toggle_settlement(work_record_id):
+        """Toggle the settlement status of a work record.
+        
+        Args:
+            work_record_id: Work record's ID
+            
+        Returns:
+            Tuple of (work_record_dict, error_message)
+            On success: (work_record_dict, None)
+            On failure: (None, error_message)
+        """
+        work_record = db.session.get(WorkRecord, work_record_id)
+        if not work_record:
+            return None, f"未找到ID为 {work_record_id} 的工作记录"
+        
+        work_record.is_settled = not work_record.is_settled
+        db.session.commit()
+        
+        return work_record.to_dict(), None
+
+    @staticmethod
+    def batch_update_settlement(year, month, is_settled, person_id=None, supplier_id=None):
+        """Batch update settlement status for work records matching criteria.
+        
+        All filters are applied as intersection (AND logic).
+        
+        Args:
+            year: Year to filter by (required)
+            month: Month to filter by 1-12 (required)
+            is_settled: New settlement status (required)
+            person_id: Filter by person ID (optional)
+            supplier_id: Filter by supplier ID (optional), use "none" to filter items without supplier
+            
+        Returns:
+            Tuple of (result_dict, error_message)
+            On success: (result_dict with updated_count, None)
+            On failure: (None, error_message)
+        """
+        # Calculate month date range
+        month_start = date(year, month, 1)
+        if month == 12:
+            month_end = date(year + 1, 1, 1)
+        else:
+            month_end = date(year, month + 1, 1)
+        
+        # Build query
+        query = WorkRecord.query.filter(
+            WorkRecord.work_date >= month_start,
+            WorkRecord.work_date < month_end
+        )
+        
+        if person_id is not None:
+            query = query.filter(WorkRecord.person_id == person_id)
+        
+        if supplier_id is not None:
+            # Join with Item to filter by supplier_id
+            query = query.join(Item)
+            if supplier_id == 'none':
+                # Filter items without supplier
+                query = query.filter(Item.supplier_id.is_(None))
+            else:
+                query = query.filter(Item.supplier_id == supplier_id)
+        
+        # Get matching records and update
+        work_records = query.all()
+        updated_count = 0
+        
+        for wr in work_records:
+            if wr.is_settled != is_settled:
+                wr.is_settled = is_settled
+                updated_count += 1
+        
+        db.session.commit()
+        
+        return {
+            'updated_count': updated_count,
+            'total_matched': len(work_records)
+        }, None

+ 1 - 0
backend/migrations/__init__.py

@@ -0,0 +1 @@
+"""Database migrations package for Work Statistics System."""

+ 290 - 0
backend/migrations/add_supplier_and_settlement.py

@@ -0,0 +1,290 @@
+"""
+Database migration script for Supplier Management feature.
+
+This script adds:
+1. suppliers table with unique name constraint
+2. Unique index on persons.name
+3. Unique index on items.name
+4. supplier_id foreign key on items table
+5. is_settled field on work_records table
+
+Compatible with both SQLite and PostgreSQL.
+
+Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7
+"""
+import os
+import sys
+from datetime import datetime, timezone
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from sqlalchemy import create_engine, text, inspect
+from sqlalchemy.exc import OperationalError, IntegrityError
+
+
+def get_database_url():
+    """Get database URL from environment or use default SQLite."""
+    return os.environ.get('DATABASE_URL') or \
+        'sqlite:///' + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'dev.db')
+
+
+def is_sqlite(engine):
+    """Check if the database is SQLite."""
+    return 'sqlite' in engine.dialect.name.lower()
+
+
+def is_postgresql(engine):
+    """Check if the database is PostgreSQL."""
+    return 'postgresql' in engine.dialect.name.lower()
+
+
+def index_exists(engine, table_name, index_name):
+    """Check if an index exists on a table."""
+    inspector = inspect(engine)
+    indexes = inspector.get_indexes(table_name)
+    return any(idx['name'] == index_name for idx in indexes)
+
+
+def column_exists(engine, table_name, column_name):
+    """Check if a column exists in a table."""
+    inspector = inspect(engine)
+    columns = inspector.get_columns(table_name)
+    return any(col['name'] == column_name for col in columns)
+
+
+def table_exists(engine, table_name):
+    """Check if a table exists in the database."""
+    inspector = inspect(engine)
+    return table_name in inspector.get_table_names()
+
+
+def upgrade(engine):
+    """
+    Upgrade the database schema.
+    
+    Operations:
+    1. Create suppliers table with unique name index
+    2. Add unique index on persons.name
+    3. Add unique index on items.name
+    4. Add supplier_id column to items table
+    5. Add is_settled column to work_records table
+    """
+    with engine.connect() as conn:
+        # 1. Create suppliers table
+        if not table_exists(engine, 'suppliers'):
+            print("Creating suppliers table...")
+            conn.execute(text("""
+                CREATE TABLE suppliers (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    name VARCHAR(100) NOT NULL,
+                    created_at DATETIME,
+                    updated_at DATETIME
+                )
+            """) if is_sqlite(engine) else text("""
+                CREATE TABLE suppliers (
+                    id SERIAL PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    created_at TIMESTAMP,
+                    updated_at TIMESTAMP
+                )
+            """))
+            conn.commit()
+            print("  - suppliers table created")
+        else:
+            print("  - suppliers table already exists, skipping")
+        
+        # 2. Add unique index on suppliers.name
+        if not index_exists(engine, 'suppliers', 'ix_suppliers_name'):
+            print("Adding unique index on suppliers.name...")
+            conn.execute(text(
+                "CREATE UNIQUE INDEX ix_suppliers_name ON suppliers (name)"
+            ))
+            conn.commit()
+            print("  - ix_suppliers_name index created")
+        else:
+            print("  - ix_suppliers_name index already exists, skipping")
+        
+        # 3. Add unique index on persons.name
+        if not index_exists(engine, 'persons', 'ix_persons_name_unique'):
+            print("Adding unique index on persons.name...")
+            try:
+                conn.execute(text(
+                    "CREATE UNIQUE INDEX ix_persons_name_unique ON persons (name)"
+                ))
+                conn.commit()
+                print("  - ix_persons_name_unique index created")
+            except (OperationalError, IntegrityError) as e:
+                print(f"  - Warning: Could not create unique index on persons.name: {e}")
+                print("    This may be due to duplicate names in existing data.")
+                conn.rollback()
+        else:
+            print("  - ix_persons_name_unique index already exists, skipping")
+        
+        # 4. Add unique index on items.name
+        if not index_exists(engine, 'items', 'ix_items_name_unique'):
+            print("Adding unique index on items.name...")
+            try:
+                conn.execute(text(
+                    "CREATE UNIQUE INDEX ix_items_name_unique ON items (name)"
+                ))
+                conn.commit()
+                print("  - ix_items_name_unique index created")
+            except (OperationalError, IntegrityError) as e:
+                print(f"  - Warning: Could not create unique index on items.name: {e}")
+                print("    This may be due to duplicate names in existing data.")
+                conn.rollback()
+        else:
+            print("  - ix_items_name_unique index already exists, skipping")
+        
+        # 5. Add supplier_id column to items table
+        if not column_exists(engine, 'items', 'supplier_id'):
+            print("Adding supplier_id column to items table...")
+            conn.execute(text(
+                "ALTER TABLE items ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)"
+            ))
+            conn.commit()
+            print("  - supplier_id column added to items")
+        else:
+            print("  - supplier_id column already exists in items, skipping")
+        
+        # 6. Add is_settled column to work_records table
+        if not column_exists(engine, 'work_records', 'is_settled'):
+            print("Adding is_settled column to work_records table...")
+            if is_sqlite(engine):
+                # SQLite doesn't support DEFAULT in ALTER TABLE well, 
+                # so we add the column and then update existing rows
+                conn.execute(text(
+                    "ALTER TABLE work_records ADD COLUMN is_settled BOOLEAN DEFAULT 0"
+                ))
+                conn.execute(text(
+                    "UPDATE work_records SET is_settled = 0 WHERE is_settled IS NULL"
+                ))
+            else:
+                # PostgreSQL supports DEFAULT in ALTER TABLE
+                conn.execute(text(
+                    "ALTER TABLE work_records ADD COLUMN is_settled BOOLEAN NOT NULL DEFAULT FALSE"
+                ))
+            conn.commit()
+            print("  - is_settled column added to work_records")
+        else:
+            print("  - is_settled column already exists in work_records, skipping")
+    
+    print("\nMigration upgrade completed successfully!")
+
+
+def downgrade(engine):
+    """
+    Downgrade the database schema (reverse the migration).
+    
+    Operations:
+    1. Remove is_settled column from work_records
+    2. Remove supplier_id column from items
+    3. Remove unique index from items.name
+    4. Remove unique index from persons.name
+    5. Remove unique index from suppliers.name
+    6. Drop suppliers table
+    """
+    with engine.connect() as conn:
+        # For SQLite, we need to recreate tables to remove columns
+        # For PostgreSQL, we can use ALTER TABLE DROP COLUMN
+        
+        if is_sqlite(engine):
+            print("SQLite detected - column removal requires table recreation")
+            print("Downgrade for SQLite is not fully supported.")
+            print("Please manually recreate the database if needed.")
+            
+            # We can still drop indexes and the suppliers table
+            print("Dropping indexes...")
+            try:
+                conn.execute(text("DROP INDEX IF EXISTS ix_items_name_unique"))
+                conn.execute(text("DROP INDEX IF EXISTS ix_persons_name_unique"))
+                conn.execute(text("DROP INDEX IF EXISTS ix_suppliers_name"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+            
+            print("Dropping suppliers table...")
+            try:
+                conn.execute(text("DROP TABLE IF EXISTS suppliers"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+        else:
+            # PostgreSQL downgrade
+            print("Removing is_settled column from work_records...")
+            try:
+                conn.execute(text("ALTER TABLE work_records DROP COLUMN IF EXISTS is_settled"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+            
+            print("Removing supplier_id column from items...")
+            try:
+                conn.execute(text("ALTER TABLE items DROP COLUMN IF EXISTS supplier_id"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+            
+            print("Dropping indexes...")
+            try:
+                conn.execute(text("DROP INDEX IF EXISTS ix_items_name_unique"))
+                conn.execute(text("DROP INDEX IF EXISTS ix_persons_name_unique"))
+                conn.execute(text("DROP INDEX IF EXISTS ix_suppliers_name"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+            
+            print("Dropping suppliers table...")
+            try:
+                conn.execute(text("DROP TABLE IF EXISTS suppliers"))
+                conn.commit()
+            except Exception as e:
+                print(f"  - Warning: {e}")
+                conn.rollback()
+    
+    print("\nMigration downgrade completed!")
+
+
+def main():
+    """Main entry point for the migration script."""
+    import argparse
+    
+    parser = argparse.ArgumentParser(
+        description='Database migration for Supplier Management feature'
+    )
+    parser.add_argument(
+        'action',
+        choices=['upgrade', 'downgrade'],
+        help='Migration action to perform'
+    )
+    parser.add_argument(
+        '--database-url',
+        help='Database URL (overrides DATABASE_URL environment variable)'
+    )
+    
+    args = parser.parse_args()
+    
+    # Get database URL
+    database_url = args.database_url or get_database_url()
+    print(f"Database: {database_url}")
+    
+    # Create engine
+    engine = create_engine(database_url)
+    
+    # Perform migration
+    if args.action == 'upgrade':
+        print("\n=== Running upgrade migration ===\n")
+        upgrade(engine)
+    else:
+        print("\n=== Running downgrade migration ===\n")
+        downgrade(engine)
+
+
+if __name__ == '__main__':
+    main()

+ 34 - 11
backend/tests/test_export.py

@@ -102,13 +102,17 @@ class TestExportAPI:
         wb = load_workbook(BytesIO(response.data))
         detail_sheet = wb['2024年1月明细']
         
-        # Check headers
+        # Check headers (now includes supplier and settlement status columns)
         headers = [cell.value for cell in detail_sheet[1]]
-        assert headers == ['人员', '日期', '物品', '单价', '数量', '总价']
+        assert headers == ['人员', '日期', '供应商', '物品', '单价', '数量', '总价', '结算状态']
         
         # Check data rows (3 records in January)
         data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
         assert len(data_rows) == 3
+        
+        # Check settlement status column values
+        for row in data_rows:
+            assert row[7] in ['已结算', '未结算']
     
     def test_monthly_export_summary_sheet(self, client, sample_data, auth_headers):
         """Test monthly export summary sheet content."""
@@ -117,17 +121,21 @@ class TestExportAPI:
         wb = load_workbook(BytesIO(response.data))
         summary_sheet = wb['月度汇总']
         
-        # Check headers
+        # Check headers (now includes supplier and settlement status columns)
         headers = [cell.value for cell in summary_sheet[1]]
-        assert headers == ['人员', '总金额']
+        assert headers == ['人员', '供应商', '总金额', '结算状态']
         
-        # Check that summary has person rows plus total row
+        # Check that summary has person+supplier+settlement rows plus total row
         data_rows = list(summary_sheet.iter_rows(min_row=2, values_only=True))
         assert len(data_rows) >= 2  # At least 2 persons
         
         # Last row should be total
         last_row = data_rows[-1]
         assert last_row[0] == '合计'
+        
+        # Check settlement status column values (except total row)
+        for row in data_rows[:-1]:
+            assert row[3] in ['已结算', '未结算']
     
     def test_monthly_export_missing_year(self, client, sample_data, auth_headers):
         """Test monthly export with missing year parameter."""
@@ -189,13 +197,17 @@ class TestExportAPI:
         wb = load_workbook(BytesIO(response.data))
         detail_sheet = wb['2024年明细']
         
-        # Check headers
+        # Check headers (now includes supplier and settlement status columns)
         headers = [cell.value for cell in detail_sheet[1]]
-        assert headers == ['人员', '日期', '物品', '单价', '数量', '总价']
+        assert headers == ['人员', '日期', '供应商', '物品', '单价', '数量', '总价', '结算状态']
         
         # Check data rows (5 records total in 2024)
         data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
         assert len(data_rows) == 5
+        
+        # Check settlement status column values
+        for row in data_rows:
+            assert row[7] in ['已结算', '未结算']
     
     def test_yearly_export_summary_sheet(self, client, sample_data, auth_headers):
         """Test yearly export summary sheet with monthly breakdown."""
@@ -204,12 +216,14 @@ class TestExportAPI:
         wb = load_workbook(BytesIO(response.data))
         summary_sheet = wb['年度汇总']
         
-        # Check headers: 人员, 1月-12月, 年度合计
+        # Check headers: 人员, 1月-12月, 年度合计, 已结算, 未结算
         headers = [cell.value for cell in summary_sheet[1]]
         assert headers[0] == '人员'
         assert headers[1] == '1月'
         assert headers[12] == '12月'
         assert headers[13] == '年度合计'
+        assert headers[14] == '已结算'
+        assert headers[15] == '未结算'
         
         # Check that summary has person rows plus total row
         data_rows = list(summary_sheet.iter_rows(min_row=2, values_only=True))
@@ -218,6 +232,14 @@ class TestExportAPI:
         # Last row should be total
         last_row = data_rows[-1]
         assert last_row[0] == '合计'
+        
+        # Check that settled + unsettled = yearly total for each person row
+        for row in data_rows:
+            yearly_total = row[13]
+            settled = row[14]
+            unsettled = row[15]
+            if yearly_total is not None and settled is not None and unsettled is not None:
+                assert abs(yearly_total - (settled + unsettled)) < 0.01
     
     def test_yearly_export_missing_year(self, client, sample_data, auth_headers):
         """Test yearly export with missing year parameter."""
@@ -249,11 +271,12 @@ class TestExportAPI:
         detail_sheet = wb['2024年1月明细']
         
         # Check each row's total_price = unit_price * quantity
+        # Column indices: 0=人员, 1=日期, 2=供应商, 3=物品, 4=单价, 5=数量, 6=总价
         for row in detail_sheet.iter_rows(min_row=2, values_only=True):
             if row[0] is not None:  # Skip empty rows
-                unit_price = row[3]
-                quantity = row[4]
-                total_price = row[5]
+                unit_price = row[4]
+                quantity = row[5]
+                total_price = row[6]
                 assert abs(total_price - (unit_price * quantity)) < 0.01
     
     def test_unauthorized_access(self, client, app):

+ 2 - 1
backend/tests/test_item.py

@@ -152,9 +152,10 @@ class TestItemAPI:
             'name': 'Updated Item'
         }, headers=auth_headers)
         
-        assert response.status_code == 400
+        assert response.status_code == 404
         data = response.get_json()
         assert data['success'] is False
+        assert data['code'] == 'NOT_FOUND'
     
     def test_update_item_empty_name(self, client, db_session, auth_headers):
         """Test updating an item with empty name fails."""

+ 2 - 1
backend/tests/test_person.py

@@ -78,9 +78,10 @@ class TestPersonAPI:
     def test_update_person_not_found(self, client, db_session, auth_headers):
         """Test updating a non-existent person."""
         response = client.post('/api/persons/update', json={'id': 9999, 'name': 'New Name'}, headers=auth_headers)
-        assert response.status_code == 400
+        assert response.status_code == 404
         data = response.get_json()
         assert data['success'] is False
+        assert data['code'] == 'NOT_FOUND'
     
     def test_update_person_empty_name(self, client, db_session, auth_headers):
         """Test updating a person with empty name fails."""

+ 2 - 0
frontend/src/App.jsx

@@ -10,6 +10,7 @@ import Dashboard from './components/Dashboard'
 import Export from './components/Export'
 import Import from './components/Import'
 import AdminList from './components/AdminList'
+import SupplierList from './components/SupplierList'
 
 function App() {
   return (
@@ -28,6 +29,7 @@ function App() {
           <Route path="dashboard" element={<Dashboard />} />
           <Route path="persons" element={<PersonList />} />
           <Route path="items" element={<ItemList />} />
+          <Route path="suppliers" element={<SupplierList />} />
           <Route path="work-records" element={<WorkRecordList />} />
           <Route path="export" element={<Export />} />
           <Route path="import" element={<Import />} />

+ 0 - 6
frontend/src/components/AdminList.jsx

@@ -78,12 +78,6 @@ function AdminList() {
 
 
   const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-      key: 'id',
-      width: isMobile ? undefined : 80
-    },
     {
       title: '用户名',
       dataIndex: 'username',

+ 180 - 61
frontend/src/components/Dashboard.jsx

@@ -9,10 +9,12 @@ import {
   ReloadOutlined,
   TrophyOutlined,
   ShoppingOutlined,
-  CalendarOutlined
+  CalendarOutlined,
+  CheckCircleOutlined,
+  ClockCircleOutlined
 } from '@ant-design/icons'
 import dayjs from 'dayjs'
-import { workRecordApi, personApi, itemApi } from '../services/api'
+import { workRecordApi, personApi, itemApi, supplierApi } from '../services/api'
 
 const MOBILE_BREAKPOINT = 768
 
@@ -30,6 +32,7 @@ function Dashboard() {
   const [yearlySummary, setYearlySummary] = useState(null)
   const [personCount, setPersonCount] = useState(0)
   const [itemCount, setItemCount] = useState(0)
+  const [supplierCount, setSupplierCount] = useState(0)
   const [error, setError] = useState(null)
   const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
 
@@ -90,16 +93,18 @@ function Dashboard() {
     }
   }
 
-  // Fetch counts for persons and items
+  // Fetch counts for persons, items, and suppliers
   const fetchCounts = async () => {
     setCountsLoading(true)
     try {
-      const [personsRes, itemsRes] = await Promise.all([
+      const [personsRes, itemsRes, suppliersRes] = await Promise.all([
         personApi.getAll(),
-        itemApi.getAll()
+        itemApi.getAll(),
+        supplierApi.getAll()
       ])
       setPersonCount(personsRes.data?.length || 0)
       setItemCount(itemsRes.data?.length || 0)
+      setSupplierCount(suppliersRes.data?.length || 0)
     } catch (error) {
       console.error('Failed to fetch counts:', error)
       message.error('获取统计数据失败')
@@ -154,6 +159,12 @@ function Dashboard() {
       dataIndex: 'person_name',
       key: 'person_name'
     },
+    {
+      title: '供应商',
+      dataIndex: 'supplier_name',
+      key: 'supplier_name',
+      render: (value) => value || ''
+    },
     {
       title: '总件数',
       dataIndex: 'total_items',
@@ -197,6 +208,12 @@ function Dashboard() {
       dataIndex: 'item_name',
       key: 'item_name'
     },
+    {
+      title: '供应商',
+      dataIndex: 'supplier_name',
+      key: 'supplier_name',
+      render: (value) => value || ''
+    },
     {
       title: '数量',
       dataIndex: 'quantity',
@@ -212,6 +229,39 @@ function Dashboard() {
     }
   ]
 
+  // Table columns for supplier breakdown (monthly)
+  const supplierBreakdownColumns = [
+    {
+      title: '人员',
+      dataIndex: 'person_name',
+      key: 'person_name'
+    },
+    {
+      title: '供应商',
+      dataIndex: 'supplier_name',
+      key: 'supplier_name',
+      render: (value) => value || ''
+    },
+    {
+      title: '收入',
+      dataIndex: 'earnings',
+      key: 'earnings',
+      align: 'right',
+      render: (value) => `¥${value.toFixed(2)}`
+    },
+    {
+      title: '结算状态',
+      dataIndex: 'is_settled',
+      key: 'is_settled',
+      align: 'center',
+      render: (value) => value ? (
+        <span style={{ color: '#000000' }}>已结算</span>
+      ) : (
+        <span style={{ color: '#fa8c16' }}>未结算</span>
+      )
+    }
+  ]
+
   // Table columns for yearly summary
   const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
   const yearlySummaryColumns = [
@@ -222,6 +272,17 @@ function Dashboard() {
       fixed: 'left',
       width: 100
     },
+    {
+      title: '结算状态',
+      key: 'settlement_status',
+      width: 200,
+      render: (_, record) => (
+        <div>
+          <div style={{ color: '#52c41a' }}>已结算: ¥{(record.settled_total || 0).toFixed(2)}</div>
+          <div style={{ color: '#fa8c16' }}>未结算: ¥{(record.unsettled_total || 0).toFixed(2)}</div>
+        </div>
+      )
+    },
     ...monthNames.map((name, index) => ({
       title: name,
       dataIndex: ['monthly_earnings', index],
@@ -276,7 +337,7 @@ function Dashboard() {
       >
         <Spin spinning={monthlyLoading}>
           <Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 24 }}>
-            <Col xs={24} sm={12}>
+            <Col xs={24} sm={12} md={6}>
               <Card>
                 <Statistic
                   title="本月记录数"
@@ -285,7 +346,7 @@ function Dashboard() {
                 />
               </Card>
             </Col>
-            <Col xs={24} sm={12}>
+            <Col xs={24} sm={12} md={6}>
               <Card>
                 <Statistic
                   title="本月总收入"
@@ -296,6 +357,30 @@ function Dashboard() {
                 />
               </Card>
             </Col>
+            <Col xs={24} sm={12} md={6}>
+              <Card>
+                <Statistic
+                  title="已结算收入"
+                  value={monthlySummary?.settled_earnings || 0}
+                  precision={2}
+                  prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
+                  suffix="元"
+                  valueStyle={{ color: '#52c41a' }}
+                />
+              </Card>
+            </Col>
+            <Col xs={24} sm={12} md={6}>
+              <Card>
+                <Statistic
+                  title="未结算收入"
+                  value={monthlySummary?.unsettled_earnings || 0}
+                  precision={2}
+                  prefix={<ClockCircleOutlined style={{ color: '#fa8c16' }} />}
+                  suffix="元"
+                  valueStyle={{ color: '#fa8c16' }}
+                />
+              </Card>
+            </Col>
           </Row>
 
           <Row gutter={[16, 16]}>
@@ -348,57 +433,34 @@ function Dashboard() {
               </Card>
             </Col>
           </Row>
-        </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' }}
-              />
+          <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
+            <Col xs={24}>
+              <Card 
+                title={
+                  <span>
+                    <UserOutlined style={{ marginRight: 8 }} />
+                    人员按供应商收入明细
+                  </span>
+                }
+                size="small"
+              >
+                {monthlySummary?.supplier_breakdown?.length > 0 ? (
+                  <Table
+                    columns={supplierBreakdownColumns}
+                    dataSource={monthlySummary.supplier_breakdown}
+                    rowKey={(record) => `${record.person_id}-${record.supplier_name}-${record.is_settled}`}
+                    pagination={false}
+                    size="small"
+                    tableLayout="auto"
+                    rowClassName={(record) => record.is_settled === false ? 'unsettled-row' : ''}
+                  />
+                ) : (
+                  <Empty description="暂无数据" />
+                )}
+              </Card>
             </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>
 
@@ -463,9 +525,9 @@ function Dashboard() {
         <Col xs={12} sm={12} md={6}>
           <Card>
             <Statistic
-              title="系统人员/物品"
+              title="系统人员/物品/供应商"
               value={personCount}
-              suffix={`/ ${itemCount}`}
+              suffix={`/ ${itemCount} / ${supplierCount}`}
               prefix={<UserOutlined />}
               loading={countsLoading}
             />
@@ -473,13 +535,13 @@ function Dashboard() {
         </Col>
       </Row>
 
-      <Card title={`${selectedDate.format('YYYY-MM-DD')} 工作统计详情`}>
+      <Card title={`${selectedDate.format('YYYY-MM-DD')} 工作统计详情`} style={{ marginBottom: isMobile ? 12 : 24 }}>
         <Spin spinning={loading}>
           {summary?.summary?.length > 0 ? (
             <Table
               columns={columns}
               dataSource={summary.summary}
-              rowKey="person_id"
+              rowKey={(record) => `${record.person_id}-${record.supplier_name}`}
               pagination={false}
               tableLayout="auto"
               size={isMobile ? 'small' : 'middle'}
@@ -489,10 +551,11 @@ function Dashboard() {
                     <Table.Summary.Cell index={0}>
                       <strong>合计</strong>
                     </Table.Summary.Cell>
-                    <Table.Summary.Cell index={1} align="right">
+                    <Table.Summary.Cell index={1}></Table.Summary.Cell>
+                    <Table.Summary.Cell index={2} align="right">
                       <strong>{summary.grand_total_items}</strong>
                     </Table.Summary.Cell>
-                    <Table.Summary.Cell index={2} align="right">
+                    <Table.Summary.Cell index={3} align="right">
                       <strong>¥{summary.grand_total_value.toFixed(2)}</strong>
                     </Table.Summary.Cell>
                   </Table.Summary.Row>
@@ -504,6 +567,62 @@ function Dashboard() {
           )}
         </Spin>
       </Card>
+
+      {/* Yearly Summary Section - moved to bottom */}
+      <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>
+        }
+      >
+        <Spin spinning={yearlyLoading}>
+          {yearlySummary?.persons?.length > 0 ? (
+            <Table
+              columns={yearlySummaryColumns}
+              dataSource={yearlySummary.persons}
+              rowKey="person_id"
+              pagination={false}
+              size="small"
+              scroll={{ x: 1400 }}
+              summary={() => (
+                <Table.Summary fixed>
+                  <Table.Summary.Row>
+                    <Table.Summary.Cell index={0} fixed="left">
+                      <strong>合计</strong>
+                    </Table.Summary.Cell>
+                    <Table.Summary.Cell index={1}>
+                      <div>
+                        <div style={{ color: '#52c41a' }}>已结算: ¥{(yearlySummary.settled_grand_total || 0).toFixed(2)}</div>
+                        <div style={{ color: '#fa8c16' }}>未结算: ¥{(yearlySummary.unsettled_grand_total || 0).toFixed(2)}</div>
+                      </div>
+                    </Table.Summary.Cell>
+                    {(yearlySummary.monthly_totals || []).map((total, index) => (
+                      <Table.Summary.Cell key={index} index={index + 2} align="right">
+                        <strong>¥{(total || 0).toFixed(2)}</strong>
+                      </Table.Summary.Cell>
+                    ))}
+                    <Table.Summary.Cell index={14} 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>
   )
 }

+ 28 - 3
frontend/src/components/ItemForm.jsx

@@ -1,10 +1,10 @@
 import { useEffect, useState } from 'react'
-import { Modal, Form, Input, InputNumber, message, Spin } from 'antd'
+import { Modal, Form, Input, InputNumber, Select, message, Spin } from 'antd'
 import { itemApi } from '../services/api'
 
 const MOBILE_BREAKPOINT = 768
 
-function ItemForm({ visible, item, onSuccess, onCancel }) {
+function ItemForm({ visible, item, suppliers = [], onSuccess, onCancel }) {
   const [form] = Form.useForm()
   const [submitting, setSubmitting] = useState(false)
   const [loading, setLoading] = useState(false)
@@ -25,7 +25,8 @@ function ItemForm({ visible, item, onSuccess, onCancel }) {
       if (item) {
         form.setFieldsValue({
           name: item.name,
-          unit_price: item.unit_price
+          unit_price: item.unit_price,
+          supplier_id: item.supplier_id ?? undefined
         })
       } else {
         form.resetFields()
@@ -118,6 +119,30 @@ function ItemForm({ visible, item, onSuccess, onCancel }) {
               disabled={loading}
             />
           </Form.Item>
+          <Form.Item
+            name="supplier_id"
+            label="供应商"
+          >
+            <Select
+              placeholder="请选择供应商(可选)"
+              allowClear
+              disabled={loading}
+              showSearch
+              optionFilterProp="children"
+              filterOption={(input, option) =>
+                (option?.children ?? '').toLowerCase().includes(input.toLowerCase())
+              }
+            >
+              <Select.Option key="none" value={-1}>
+                无供应商
+              </Select.Option>
+              {suppliers.map(supplier => (
+                <Select.Option key={supplier.id} value={supplier.id}>
+                  {supplier.name}
+                </Select.Option>
+              ))}
+            </Select>
+          </Form.Item>
         </Form>
       </Spin>
     </Modal>

+ 20 - 7
frontend/src/components/ItemList.jsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react'
 import { Table, Button, Space, message, Popconfirm, Alert } from 'antd'
 import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
-import { itemApi } from '../services/api'
+import { itemApi, supplierApi } from '../services/api'
 import ItemForm from './ItemForm'
 import { toBeijingDateTime } from '../utils/timeUtils'
 
@@ -9,6 +9,7 @@ const MOBILE_BREAKPOINT = 768
 
 function ItemList() {
   const [items, setItems] = useState([])
+  const [suppliers, setSuppliers] = useState([])
   const [loading, setLoading] = useState(false)
   const [formVisible, setFormVisible] = useState(false)
   const [editingItem, setEditingItem] = useState(null)
@@ -39,8 +40,18 @@ function ItemList() {
     }
   }
 
+  const fetchSuppliers = async () => {
+    try {
+      const response = await supplierApi.getAll()
+      setSuppliers(response.data || [])
+    } catch (error) {
+      console.error('获取供应商列表失败:', error)
+    }
+  }
+
   useEffect(() => {
     fetchItems()
+    fetchSuppliers()
   }, [])
 
   const handleAdd = () => {
@@ -75,17 +86,18 @@ function ItemList() {
   }
 
   const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-      key: 'id',
-      width: isMobile ? undefined : 80
-    },
     {
       title: '物品名称',
       dataIndex: 'name',
       key: 'name'
     },
+    {
+      title: '供应商',
+      dataIndex: 'supplier_name',
+      key: 'supplier_name',
+      width: isMobile ? undefined : 120,
+      render: (text) => text || '-'
+    },
     {
       title: '单价',
       dataIndex: 'unit_price',
@@ -170,6 +182,7 @@ function ItemList() {
       <ItemForm
         visible={formVisible}
         item={editingItem}
+        suppliers={suppliers}
         onSuccess={handleFormSuccess}
         onCancel={handleFormCancel}
       />

+ 7 - 1
frontend/src/components/Layout.jsx

@@ -12,7 +12,8 @@ import {
   MenuUnfoldOutlined,
   TeamOutlined,
   LogoutOutlined,
-  MenuOutlined
+  MenuOutlined,
+  ShopOutlined
 } from '@ant-design/icons'
 import { useAuth } from '../contexts/AuthContext'
 
@@ -31,6 +32,11 @@ const menuItems = [
     icon: <UserOutlined />,
     label: '人员管理'
   },
+  {
+    key: '/suppliers',
+    icon: <ShopOutlined />,
+    label: '供应商管理'
+  },
   {
     key: '/items',
     icon: <AppstoreOutlined />,

+ 0 - 6
frontend/src/components/PersonList.jsx

@@ -75,12 +75,6 @@ function PersonList() {
   }
 
   const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-      key: 'id',
-      width: isMobile ? undefined : 80
-    },
     {
       title: '姓名',
       dataIndex: 'name',

+ 99 - 0
frontend/src/components/SupplierForm.jsx

@@ -0,0 +1,99 @@
+import { useEffect, useState } from 'react'
+import { Modal, Form, Input, message, Spin } from 'antd'
+import { supplierApi } from '../services/api'
+
+const MOBILE_BREAKPOINT = 768
+
+function SupplierForm({ visible, supplier, onSuccess, onCancel }) {
+  const [form] = Form.useForm()
+  const [submitting, setSubmitting] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+  const isEdit = !!supplier
+
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  useEffect(() => {
+    if (visible) {
+      setLoading(true)
+      if (supplier) {
+        form.setFieldsValue({ name: supplier.name })
+      } else {
+        form.resetFields()
+      }
+      setLoading(false)
+    }
+  }, [visible, supplier, form])
+
+  const handleOk = async () => {
+    try {
+      const values = await form.validateFields()
+      setSubmitting(true)
+      
+      if (isEdit) {
+        await supplierApi.update({ id: supplier.id, ...values })
+        message.success('更新成功')
+      } else {
+        await supplierApi.create(values)
+        message.success('创建成功')
+      }
+      
+      onSuccess()
+    } catch (error) {
+      if (error.errorFields) {
+        return
+      }
+      message.error(error.message || '操作失败')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const validateName = (_, value) => {
+    if (!value || !value.trim()) {
+      return Promise.reject(new Error('请输入供应商名称'))
+    }
+    return Promise.resolve()
+  }
+
+  return (
+    <Modal
+      title={isEdit ? '编辑供应商' : '新增供应商'}
+      open={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      confirmLoading={submitting}
+      okButtonProps={{ disabled: loading }}
+      cancelButtonProps={{ disabled: submitting }}
+      destroyOnClose
+      okText="确定"
+      cancelText="取消"
+      width={isMobile ? '100%' : 520}
+      style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 32px)', margin: '0 auto' } : undefined}
+    >
+      <Spin spinning={loading}>
+        <Form
+          form={form}
+          layout="vertical"
+          autoComplete="off"
+        >
+          <Form.Item
+            name="name"
+            label="供应商名称"
+            rules={[{ validator: validateName }]}
+          >
+            <Input placeholder="请输入供应商名称" maxLength={100} disabled={loading} />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  )
+}
+
+export default SupplierForm

+ 167 - 0
frontend/src/components/SupplierList.jsx

@@ -0,0 +1,167 @@
+import { useState, useEffect } from 'react'
+import { Table, Button, Space, message, Popconfirm, Alert } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import { supplierApi } from '../services/api'
+import SupplierForm from './SupplierForm'
+import { toBeijingDateTime } from '../utils/timeUtils'
+
+const MOBILE_BREAKPOINT = 768
+
+function SupplierList() {
+  const [suppliers, setSuppliers] = useState([])
+  const [loading, setLoading] = useState(false)
+  const [formVisible, setFormVisible] = useState(false)
+  const [editingSupplier, setEditingSupplier] = useState(null)
+  const [error, setError] = useState(null)
+  const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT)
+
+  // Mobile detection
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  const fetchSuppliers = async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await supplierApi.getAll()
+      setSuppliers(response.data || [])
+    } catch (error) {
+      const errorMsg = error.message || '获取供应商列表失败'
+      setError(errorMsg)
+      message.error(errorMsg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchSuppliers()
+  }, [])
+
+  const handleAdd = () => {
+    setEditingSupplier(null)
+    setFormVisible(true)
+  }
+
+  const handleEdit = (record) => {
+    setEditingSupplier(record)
+    setFormVisible(true)
+  }
+
+  const handleDelete = async (id) => {
+    try {
+      await supplierApi.delete(id)
+      message.success('删除成功')
+      fetchSuppliers()
+    } catch (error) {
+      message.error(error.message || '删除失败')
+    }
+  }
+
+  const handleFormSuccess = () => {
+    setFormVisible(false)
+    setEditingSupplier(null)
+    fetchSuppliers()
+  }
+
+  const handleFormCancel = () => {
+    setFormVisible(false)
+    setEditingSupplier(null)
+  }
+
+  const columns = [
+    {
+      title: '供应商名称',
+      dataIndex: 'name',
+      key: 'name'
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (text) => toBeijingDateTime(text),
+      responsive: ['md']
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: isMobile ? undefined : 150,
+      render: (_, record) => (
+        <Space size="small" wrap={isMobile}>
+          <Button
+            type="link"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+            size={isMobile ? 'small' : 'middle'}
+            style={isMobile ? { padding: '0 4px' } : undefined}
+          >
+            {!isMobile && '编辑'}
+          </Button>
+          <Popconfirm
+            title="确认删除"
+            description="确定要删除这个供应商吗?"
+            onConfirm={() => handleDelete(record.id)}
+            okText="确定"
+            cancelText="取消"
+          >
+            <Button type="link" danger icon={<DeleteOutlined />} size={isMobile ? 'small' : 'middle'} style={isMobile ? { padding: '0 4px' } : undefined}>
+              {!isMobile && '删除'}
+            </Button>
+          </Popconfirm>
+        </Space>
+      )
+    }
+  ]
+
+  return (
+    <div>
+      {error && (
+        <Alert
+          message="加载错误"
+          description={error}
+          type="error"
+          showIcon
+          closable
+          style={{ marginBottom: 16 }}
+          onClose={() => setError(null)}
+        />
+      )}
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
+        <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
+          新增供应商
+        </Button>
+        <Button icon={<ReloadOutlined />} onClick={fetchSuppliers} loading={loading}>
+          刷新
+        </Button>
+      </div>
+      <Table
+        columns={columns}
+        dataSource={suppliers}
+        rowKey="id"
+        loading={loading}
+        tableLayout="auto"
+        size={isMobile ? 'small' : 'middle'}
+        pagination={{
+          showSizeChanger: !isMobile,
+          showQuickJumper: !isMobile,
+          showTotal: (total) => `共 ${total} 条`,
+          size: isMobile ? 'small' : 'default'
+        }}
+      />
+      <SupplierForm
+        visible={formVisible}
+        supplier={editingSupplier}
+        onSuccess={handleFormSuccess}
+        onCancel={handleFormCancel}
+      />
+    </div>
+  )
+}
+
+export default SupplierList

+ 4 - 1
frontend/src/components/WorkRecordForm.jsx

@@ -142,7 +142,10 @@ function WorkRecordForm({ visible, record, onSuccess, onCancel }) {
               placeholder="请选择物品"
               showSearch
               optionFilterProp="label"
-              options={items.map(i => ({ label: `${i.name} (¥${Number(i.unit_price).toFixed(2)})`, value: i.id }))}
+              options={items.map(i => ({ 
+                label: `${i.supplier_name ? `[${i.supplier_name}] ` : ''}${i.name} (¥${Number(i.unit_price).toFixed(2)})`, 
+                value: i.id 
+              }))}
               disabled={loading}
             />
           </Form.Item>

+ 191 - 16
frontend/src/components/WorkRecordList.jsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react'
-import { Table, Button, Space, message, Popconfirm, DatePicker, Select, Row, Col, Alert } from 'antd'
-import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
-import { workRecordApi, personApi } from '../services/api'
+import { Table, Button, Space, message, Popconfirm, DatePicker, Select, Row, Col, Alert, Tag, Modal, Divider } from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SettingOutlined } from '@ant-design/icons'
+import { workRecordApi, personApi, supplierApi } from '../services/api'
 import WorkRecordForm from './WorkRecordForm'
 import { toBeijingDate } from '../utils/timeUtils'
 
@@ -10,6 +10,7 @@ const MOBILE_BREAKPOINT = 768
 function WorkRecordList() {
   const [workRecords, setWorkRecords] = useState([])
   const [persons, setPersons] = useState([])
+  const [suppliers, setSuppliers] = useState([])
   const [loading, setLoading] = useState(false)
   const [formVisible, setFormVisible] = useState(false)
   const [editingRecord, setEditingRecord] = useState(null)
@@ -20,6 +21,14 @@ function WorkRecordList() {
   const [selectedPersonId, setSelectedPersonId] = useState(null)
   const [selectedDate, setSelectedDate] = useState(null)
   const [selectedMonth, setSelectedMonth] = useState(null)
+  const [selectedSettlement, setSelectedSettlement] = useState(null)
+  
+  // Batch settlement modal states
+  const [batchModalVisible, setBatchModalVisible] = useState(false)
+  const [batchPersonId, setBatchPersonId] = useState(null)
+  const [batchMonth, setBatchMonth] = useState(null)
+  const [batchSupplierId, setBatchSupplierId] = useState(null)
+  const [batchLoading, setBatchLoading] = useState(false)
 
   // Mobile detection
   useEffect(() => {
@@ -39,6 +48,15 @@ function WorkRecordList() {
     }
   }
 
+  const fetchSuppliers = async () => {
+    try {
+      const response = await supplierApi.getAll()
+      setSuppliers(response.data || [])
+    } catch (error) {
+      message.error(error.message || '获取供应商列表失败')
+    }
+  }
+
   const fetchWorkRecords = async () => {
     setLoading(true)
     setError(null)
@@ -54,6 +72,9 @@ function WorkRecordList() {
         params.year = selectedMonth.year()
         params.month = selectedMonth.month() + 1
       }
+      if (selectedSettlement !== null) {
+        params.is_settled = selectedSettlement
+      }
       const response = await workRecordApi.getAll(params)
       setWorkRecords(response.data || [])
     } catch (error) {
@@ -67,11 +88,12 @@ function WorkRecordList() {
 
   useEffect(() => {
     fetchPersons()
+    fetchSuppliers()
   }, [])
 
   useEffect(() => {
     fetchWorkRecords()
-  }, [selectedPersonId, selectedDate, selectedMonth])
+  }, [selectedPersonId, selectedDate, selectedMonth, selectedSettlement])
 
   const handleAdd = () => {
     setEditingRecord(null)
@@ -108,16 +130,58 @@ function WorkRecordList() {
     setSelectedPersonId(null)
     setSelectedDate(null)
     setSelectedMonth(null)
+    setSelectedSettlement(null)
+  }
+
+  const handleToggleSettlement = async (record) => {
+    try {
+      await workRecordApi.toggleSettlement(record.id)
+      message.success(record.is_settled ? '已设为未结算' : '已设为已结算')
+      fetchWorkRecords()
+    } catch (error) {
+      message.error(error.message || '切换结算状态失败')
+    }
+  }
+
+  const handleOpenBatchModal = () => {
+    setBatchPersonId(null)
+    setBatchMonth(null)
+    setBatchSupplierId(null)
+    setBatchModalVisible(true)
+  }
+
+  const handleBatchSettlement = async (isSettled) => {
+    if (!batchMonth) {
+      message.warning('请选择月份')
+      return
+    }
+    
+    setBatchLoading(true)
+    try {
+      const data = {
+        year: batchMonth.year(),
+        month: batchMonth.month() + 1,
+        is_settled: isSettled
+      }
+      if (batchPersonId) {
+        data.person_id = batchPersonId
+      }
+      if (batchSupplierId) {
+        data.supplier_id = batchSupplierId
+      }
+      
+      const response = await workRecordApi.batchSettlement(data)
+      message.success(response.message || `成功更新 ${response.data.updated_count} 条记录`)
+      setBatchModalVisible(false)
+      fetchWorkRecords()
+    } catch (error) {
+      message.error(error.message || '批量更新失败')
+    } finally {
+      setBatchLoading(false)
+    }
   }
 
   const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-      key: 'id',
-      width: isMobile ? undefined : 60,
-      fixed: isMobile ? undefined : 'left'
-    },
     {
       title: '人员',
       dataIndex: 'person_name',
@@ -137,6 +201,13 @@ function WorkRecordList() {
       key: 'item_name',
       width: isMobile ? undefined : 120
     },
+    {
+      title: '供应商',
+      dataIndex: 'supplier_name',
+      key: 'supplier_name',
+      width: isMobile ? undefined : 100,
+      render: (value) => value || '-'
+    },
     {
       title: '单价',
       dataIndex: 'unit_price',
@@ -158,6 +229,25 @@ function WorkRecordList() {
       width: isMobile ? undefined : 100,
       render: (value) => `¥${Number(value).toFixed(2)}`
     },
+    {
+      title: '结算状态',
+      dataIndex: 'is_settled',
+      key: 'is_settled',
+      width: isMobile ? undefined : 100,
+      render: (isSettled, record) => (
+        <Tag
+          color={isSettled ? 'green' : 'orange'}
+          style={{ cursor: 'pointer' }}
+          onClick={() => handleToggleSettlement(record)}
+        >
+          {isSettled ? (
+            <><CheckCircleOutlined /> 已结算</>
+          ) : (
+            <><CloseCircleOutlined /> 未结算</>
+          )}
+        </Tag>
+      )
+    },
     {
       title: '操作',
       key: 'action',
@@ -204,6 +294,16 @@ function WorkRecordList() {
           onClose={() => setError(null)}
         />
       )}
+      <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
+        <Space wrap>
+          <Button icon={<SettingOutlined />} onClick={handleOpenBatchModal}>
+            批量结算
+          </Button>
+          <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
+            新增记录
+          </Button>
+        </Space>
+      </div>
       <Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
         <Col xs={12} sm={6} md={4}>
           <Select
@@ -232,16 +332,24 @@ function WorkRecordList() {
             style={{ width: '100%' }}
           />
         </Col>
+        <Col xs={12} sm={6} md={4}>
+          <Select
+            placeholder="结算状态"
+            allowClear
+            style={{ width: '100%' }}
+            value={selectedSettlement}
+            onChange={setSelectedSettlement}
+            options={[
+              { label: '已结算', value: true },
+              { label: '未结算', value: false }
+            ]}
+          />
+        </Col>
         <Col xs={12} sm={6} md={4}>
           <Button icon={<ReloadOutlined />} onClick={handleReset} style={{ width: isMobile ? '100%' : 'auto' }}>
             重置
           </Button>
         </Col>
-        <Col xs={24} sm={24} md={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
-          <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd} style={{ width: isMobile ? '100%' : 'auto' }}>
-            新增记录
-          </Button>
-        </Col>
       </Row>
       <Table
         columns={columns}
@@ -264,6 +372,73 @@ function WorkRecordList() {
         onSuccess={handleFormSuccess}
         onCancel={handleFormCancel}
       />
+      <Modal
+        title="批量结算操作"
+        open={batchModalVisible}
+        onCancel={() => setBatchModalVisible(false)}
+        footer={null}
+        width={isMobile ? '100%' : 480}
+        style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 32px)', margin: '0 auto' } : undefined}
+      >
+        <div style={{ marginBottom: 16 }}>
+          <div style={{ marginBottom: 8 }}>人员(可选)</div>
+          <Select
+            placeholder="选择人员(不选则全部)"
+            allowClear
+            style={{ width: '100%' }}
+            value={batchPersonId}
+            onChange={setBatchPersonId}
+            options={persons.map(p => ({ label: p.name, value: p.id }))}
+          />
+        </div>
+        <div style={{ marginBottom: 16 }}>
+          <div style={{ marginBottom: 8 }}>月份(必选)</div>
+          <DatePicker.MonthPicker
+            placeholder="选择月份"
+            value={batchMonth}
+            onChange={setBatchMonth}
+            format="YYYY-MM"
+            style={{ width: '100%' }}
+          />
+        </div>
+        <div style={{ marginBottom: 16 }}>
+          <div style={{ marginBottom: 8 }}>供应商(可选)</div>
+          <Select
+            placeholder="选择供应商(不选则全部)"
+            allowClear
+            style={{ width: '100%' }}
+            value={batchSupplierId}
+            onChange={setBatchSupplierId}
+          >
+            <Select.Option key="none" value="none">
+              无供应商
+            </Select.Option>
+            {suppliers.map(s => (
+              <Select.Option key={s.id} value={s.id}>
+                {s.name}
+              </Select.Option>
+            ))}
+          </Select>
+        </div>
+        <Divider />
+        <Space style={{ width: '100%', justifyContent: 'center' }}>
+          <Button
+            type="primary"
+            icon={<CheckCircleOutlined />}
+            loading={batchLoading}
+            onClick={() => handleBatchSettlement(true)}
+          >
+            设为已结算
+          </Button>
+          <Button
+            icon={<CloseCircleOutlined />}
+            loading={batchLoading}
+            onClick={() => handleBatchSettlement(false)}
+          >
+            设为未结算
+          </Button>
+        </Space>
+      </Modal>
     </div>
   )
 }

+ 9 - 0
frontend/src/index.css

@@ -161,3 +161,12 @@ body {
 .ant-menu-item::after {
   transition: none !important;
 }
+
+/* Settlement status row highlighting */
+.unsettled-row {
+  background-color: #fff7e6 !important;
+}
+
+.unsettled-row:hover > td {
+  background-color: #fff1d6 !important;
+}

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

@@ -74,6 +74,15 @@ export const itemApi = {
   delete: (id) => api.post('/items/delete', { id })
 }
 
+// Supplier API
+export const supplierApi = {
+  getAll: () => api.get('/suppliers'),
+  getById: (id) => api.get(`/suppliers/${id}`),
+  create: (data) => api.post('/suppliers/create', data),
+  update: (data) => api.post('/suppliers/update', data),
+  delete: (id) => api.post('/suppliers/delete', { id })
+}
+
 // Work Record API
 export const workRecordApi = {
   getAll: (params) => api.get('/work-records', { params }),
@@ -83,7 +92,9 @@ export const workRecordApi = {
   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 }),
-  getYearlySummary: (params) => api.get('/work-records/yearly-summary', { params })
+  getYearlySummary: (params) => api.get('/work-records/yearly-summary', { params }),
+  toggleSettlement: (id) => api.put(`/work-records/${id}/settlement`),
+  batchSettlement: (data) => api.post('/work-records/batch-settlement', data)
 }
 
 // Export API - uses axios directly to handle blob responses