本设计文档描述供应商管理功能的技术实现方案,包括:
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
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
}
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
}
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
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 ''
}
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/suppliers | 获取所有供应商列表 |
| GET | /api/suppliers/:id | 获取单个供应商详情 |
| POST | /api/suppliers | 创建新供应商 |
| PUT | /api/suppliers/:id | 更新供应商信息 |
| DELETE | /api/suppliers/:id | 删除供应商 |
| 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 // 必填
}
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"
迁移脚本需要兼容 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')
DETAIL_HEADERS = ['人员', '日期', '供应商', '物品', '单价', '数量', '总价']
月度汇总按人员和供应商分组:
# Headers: ['人员', '供应商', '总金额']
# 按 (person_name, supplier_name) 分组统计
保持现有格式不变。
{
"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
}
]
}
{
"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
}
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.
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
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
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
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
For any newly created work record, the is_settled field should default to False. Validates: Requirements 5.1
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
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 Code | Description | HTTP Status |
|---|---|---|
| DUPLICATE_NAME | 名称已存在 | 400 |
| NOT_FOUND | 资源不存在 | 404 |
| INVALID_INPUT | 输入参数无效 | 400 |
| FOREIGN_KEY_VIOLATION | 外键约束违反(如删除有关联物品的供应商) | 400 |
{
"error": "DUPLICATE_NAME",
"message": "供应商名称已存在"
}
使用 hypothesis 库进行属性测试:
Name Uniqueness Property Test
Empty Supplier Display Property Test
Settlement Toggle Property Test
Batch Settlement Filter Property Test
# conftest.py
from hypothesis import settings
settings.register_profile("ci", max_examples=100)
settings.load_profile("ci")