design.md 13 KB

Design Document: Supplier Management

Overview

本设计文档描述供应商管理功能的技术实现方案,包括:

  • 人员和物品名称唯一性约束
  • 供应商管理模块(CRUD)
  • 物品关联供应商
  • 工作记录结算状态管理
  • 导出报表增加供应商信息
  • 仪表盘供应商相关统计
  • 数据库迁移脚本

Architecture

系统架构

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)

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

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

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

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 批量更新结算状态

批量更新请求体:

{
  "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

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:

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

DETAIL_HEADERS = ['人员', '日期', '供应商', '物品', '单价', '数量', '总价']

Updated Monthly Summary Sheet

月度汇总按人员和供应商分组:

# Headers: ['人员', '供应商', '总金额']
# 按 (person_name, supplier_name) 分组统计

Yearly Summary Sheet

保持现有格式不变。

Dashboard API Updates

Monthly Summary Response

{
  "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

{
  "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

{
  "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

# conftest.py
from hypothesis import settings

settings.register_profile("ci", max_examples=100)
settings.load_profile("ci")