docs: 新增品牌实体设计文档
设计 Brand 实体、Factory.brand 重命名为 short_name、 Material 新增品牌外键的方案与迁移步骤。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d3ceaded07
commit
08b855794f
|
|
@ -0,0 +1,275 @@
|
|||
# 品牌(Brand)实体设计
|
||||
|
||||
- 日期:2026-04-23
|
||||
- 作者:brainstorm 协作产出
|
||||
- 状态:待实施
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统的 `Factory`(供应商库)表包含一个 `brand` 字段(唯一文本),`Material` 通过外键关联 `Factory`,品牌信息依附在供应商上。业务现实是:
|
||||
|
||||
- 一个供应商(经销商/工厂)可能代理多个品牌
|
||||
- 一个品牌可能由多个供应商代理
|
||||
- 即 Brand ↔ Factory 为多对多关系
|
||||
|
||||
因此需要把"品牌"从 `Factory` 中独立出来,成为一级实体,并让 `Material` 同时关联"供应商"和"品牌"。原 `Factory.brand` 字段语义上更接近"供应商简称",需要重命名以消除歧义。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增独立的 Brand 实体(品牌库)
|
||||
- Material 同时关联 Factory(供应商)和 Brand(品牌)
|
||||
- 历史数据无缝迁移到新结构
|
||||
- 前端增加"品牌库"菜单项(admin)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不建立独立的 Brand-Factory 多对多关联表(多对多关系通过 Material 间接体现,避免冗余)
|
||||
- 不为 Brand 设计除 name/description 之外的字段(logo、官网等后续按需再加)
|
||||
- 不引入软删除机制
|
||||
|
||||
## 设计决策总表
|
||||
|
||||
| 维度 | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| Brand 字段 | `name`(唯一必填)、`description`(可选) | 需求明确只要最简字段 |
|
||||
| Brand-Factory 关系 | 业务上多对多,但不建 M2M 表 | 通过 Material 间接体现,避免冗余 |
|
||||
| Material.brand | `ForeignKey(Brand, on_delete=PROTECT, null=True, blank=True)` | DB 层可空以容纳历史数据,前端必填;PROTECT 防误删 |
|
||||
| Material 与 brand 多选 | 单选(ForeignKey) | 绝大多数材料现实中只属于一个品牌 |
|
||||
| Factory.brand 处理 | 重命名为 `short_name`(保持 unique) | 语义更准,一次性改清楚 |
|
||||
| 数据迁移 | 自动迁移(从 Factory.short_name 唯一值自动建 Brand 并回填 Material.brand) | 历史数据一步到位 |
|
||||
| 迁移颗粒度 | 单次大迁移(schema + data 合并发布) | 数据量小,一次到位更省事 |
|
||||
| 菜单位置 | 顶层一级菜单"品牌库"(供应商库与材料管理之间) | 作为核心业务数据,位置显眼 |
|
||||
| 权限 | 仅 admin 可见可编辑 | 品牌是管理员维护的主数据 |
|
||||
| 品牌删除行为 | PROTECT,前端 catch 错误并提示 | 防止误删丢关联 |
|
||||
| 品牌库搜索 | 仅按 name 模糊搜索 | description 语义不适合搜索 |
|
||||
| Material 列表 | 增加"品牌"列 + 品牌筛选器 | 与现有 factory 筛选保持一致 |
|
||||
|
||||
## 整体架构
|
||||
|
||||
新增独立 Django app:`brand`,与 `factory`、`material` 同级。
|
||||
|
||||
```
|
||||
backend/apps/
|
||||
brand/ # 新增
|
||||
__init__.py
|
||||
models.py # Brand 模型
|
||||
serializers.py
|
||||
views.py
|
||||
urls.py
|
||||
migrations/
|
||||
0001_initial.py
|
||||
```
|
||||
|
||||
变更涉及三个后端 app:
|
||||
|
||||
- `brand`(新建):定义 Brand 模型、CRUD API
|
||||
- `factory`:模型字段 `brand` → `short_name`(schema migration)
|
||||
- `material`:新增 `brand = ForeignKey(Brand, …)` 字段 + 跨 app 的数据迁移
|
||||
|
||||
前端新增:`/brands` 路由 + `BrandManage.vue` 视图 + `api/brand.js`,菜单加一项"品牌库"(admin 可见);修改 `MaterialManage.vue` 增加品牌列 / 筛选器 / 表单下拉;同步改 `FactoryManage.vue` 文案。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### Brand 模型
|
||||
|
||||
```python
|
||||
# backend/apps/brand/models.py
|
||||
from django.db import models
|
||||
|
||||
class Brand(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name='品牌名称')
|
||||
description = models.TextField(blank=True, null=True, verbose_name='品牌描述')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '品牌'
|
||||
verbose_name_plural = '品牌'
|
||||
db_table = 'brand'
|
||||
ordering = ['id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### Factory 字段重命名
|
||||
|
||||
```python
|
||||
# backend/apps/factory/models.py
|
||||
# 原:brand = models.CharField(max_length=100, unique=True, verbose_name='品牌')
|
||||
# 新:
|
||||
short_name = models.CharField(max_length=100, unique=True, verbose_name='供应商简称')
|
||||
```
|
||||
|
||||
### Material 新增外键
|
||||
|
||||
```python
|
||||
# backend/apps/material/models.py
|
||||
brand = models.ForeignKey(
|
||||
'brand.Brand',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='materials',
|
||||
verbose_name='品牌',
|
||||
)
|
||||
```
|
||||
|
||||
### 迁移顺序
|
||||
|
||||
1. `brand/migrations/0001_initial.py` — 建 Brand 表
|
||||
2. `factory/migrations/0002_rename_brand_short_name.py` — 重命名字段(手写 `migrations.RenameField`,避免依赖 makemigrations 的交互式识别)
|
||||
3. `material/migrations/0002_add_brand_fk.py` — 新增 Material.brand 外键(依赖 brand.0001)
|
||||
4. `material/migrations/0003_populate_brand.py` — 数据迁移(`RunPython`,依赖 factory.0002 和 material.0002)
|
||||
|
||||
### 数据迁移脚本
|
||||
|
||||
```python
|
||||
# material/migrations/0003_populate_brand.py
|
||||
from django.db import migrations
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Factory = apps.get_model('factory', 'Factory')
|
||||
Brand = apps.get_model('brand', 'Brand')
|
||||
Material = apps.get_model('material', 'Material')
|
||||
|
||||
# 1) 从 Factory.short_name 唯一值创建 Brand
|
||||
for sn in Factory.objects.values_list('short_name', flat=True).distinct():
|
||||
if sn:
|
||||
Brand.objects.get_or_create(name=sn)
|
||||
|
||||
# 2) 回填 Material.brand
|
||||
for material in Material.objects.select_related('factory').all():
|
||||
if material.factory and material.factory.short_name:
|
||||
brand = Brand.objects.filter(name=material.factory.short_name).first()
|
||||
if brand:
|
||||
material.brand = brand
|
||||
material.save(update_fields=['brand'])
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
# schema migration 回滚时会删掉 Material.brand 字段,这里无需操作
|
||||
pass
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('material', '0002_add_brand_fk'),
|
||||
('factory', '0002_rename_brand_short_name'),
|
||||
('brand', '0001_initial'),
|
||||
]
|
||||
operations = [migrations.RunPython(forwards, backwards)]
|
||||
```
|
||||
|
||||
### API 端点
|
||||
|
||||
`/api/brand/`(仅 admin 写,读可对所有登录用户开放,但菜单只 admin 可见):
|
||||
|
||||
- `GET /api/brand/` — 列表,支持 `search=<name>` 模糊搜索与分页
|
||||
- `POST /api/brand/` — 创建
|
||||
- `GET /api/brand/{id}/` — 详情
|
||||
- `PUT /api/brand/{id}/` — 更新
|
||||
- `DELETE /api/brand/{id}/` — 删除;若被 Material 引用则 PROTECT 报错,viewset 需捕获 `ProtectedError` 返回 400 + 中文提示
|
||||
|
||||
Material 现有接口调整:
|
||||
|
||||
- `GET /api/material/` 新增 `brand` query param(按品牌 id 过滤)
|
||||
- Material serializer 增加 `brand` 字段(读:嵌套 `{id, name}`;写:接受 id)
|
||||
|
||||
Factory 接口调整:
|
||||
|
||||
- serializer / filterset / ordering 中所有 `brand` 引用同步改为 `short_name`
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `frontend/src/api/brand.js` — 封装 `listBrands` / `createBrand` / `updateBrand` / `deleteBrand`
|
||||
- `frontend/src/views/BrandManage.vue` — 品牌库页面(参考 `DictionaryManage.vue` 风格)
|
||||
|
||||
### 路由与菜单
|
||||
|
||||
- `router/index.js`:MainLayout 的 children 中加 `{ path: 'brands', name: 'brands', component: () => import('@/views/BrandManage.vue'), meta: { admin: true } }`
|
||||
- `layouts/MainLayout.vue`:在"供应商库"与"材料管理"之间加 `<el-menu-item v-if="isAdmin" index="/brands">品牌库</el-menu-item>`
|
||||
|
||||
菜单顺序:用户管理 / 供应商库 / **品牌库** / 材料管理 / 数据大屏 / 配置项。
|
||||
|
||||
### BrandManage.vue 结构
|
||||
|
||||
- 顶部工具栏:按名称模糊搜索输入框 + "新增品牌"按钮
|
||||
- 表格列:ID / 品牌名称 / 品牌描述 / 创建时间 / 操作(编辑 / 删除)
|
||||
- 弹窗表单:
|
||||
- 名称(`required`,唯一冲突时后端返回错误,前端 catch 并提示)
|
||||
- 描述(textarea,可选)
|
||||
- 分页
|
||||
- 删除确认:点删除先 confirm,后端返回 400(PROTECT 触发)时 catch 并提示"该品牌下存在材料,无法删除"
|
||||
|
||||
### MaterialManage.vue 改动
|
||||
|
||||
- 列表页:
|
||||
- 新增"品牌"列,展示 `row.brand?.name`
|
||||
- 筛选区新增"品牌"下拉(数据从 `/api/brand/` 拉取,支持远程搜索)
|
||||
- 新增 / 编辑弹窗表单:
|
||||
- 增加"品牌"字段,`el-select` 远程搜索,前端标记 `required`
|
||||
- 位置紧挨"供应商"字段后
|
||||
- 提交时把 brand(id)纳入 payload
|
||||
|
||||
### MaterialDetail.vue 改动
|
||||
|
||||
- 详情页展示"品牌"字段,位置紧挨"供应商"
|
||||
|
||||
### FactoryManage.vue 改动
|
||||
|
||||
- 表格列 / 表单 label / 搜索筛选器中所有"品牌"文案改为"供应商简称"
|
||||
- 字段 key 从 `brand` 改为 `short_name`
|
||||
- 用 grep 扫一遍 `frontend/src` 确保无遗漏
|
||||
|
||||
## 实施步骤
|
||||
|
||||
严格按以下顺序执行,便于单步验证:
|
||||
|
||||
1. **后端模型与迁移**
|
||||
- 创建 `backend/apps/brand/` app 骨架
|
||||
- 在 `config/settings.py` 的 `INSTALLED_APPS` 注册 `apps.brand`
|
||||
- 在 `config/urls.py` 挂载 `path('api/brand/', include('apps.brand.urls'))`
|
||||
- 生成 Brand `0001_initial`
|
||||
- 修改 Factory 模型,手写 rename migration
|
||||
- 修改 Material 模型,生成 add FK migration
|
||||
- 写 `populate_brand` data migration
|
||||
- 本地 `python manage.py migrate` 验证
|
||||
|
||||
2. **后端 API & serializer**
|
||||
- Brand CRUD(admin 权限)
|
||||
- Material serializer 增加 brand 字段;filterset 增加 brand
|
||||
- Factory serializer / view 同步把 brand → short_name
|
||||
|
||||
3. **前端品牌库**
|
||||
- `api/brand.js` + `BrandManage.vue` + 路由 + 菜单
|
||||
|
||||
4. **前端材料页面改造**
|
||||
- MaterialManage 列表列 / 筛选器 / 表单下拉
|
||||
- MaterialDetail 显示品牌
|
||||
- FactoryManage 文案同步
|
||||
|
||||
5. **联调验证**
|
||||
- 品牌库看到从 Factory.short_name 迁移出的品牌
|
||||
- 材料列表品牌列已填
|
||||
- 新增材料时品牌下拉可用并必填
|
||||
- 删除被引用的品牌应失败并提示
|
||||
|
||||
## 风险与应对
|
||||
|
||||
- **Django rename migration 的交互坑**:`makemigrations` 会交互询问是否重命名,自动化环境不友好。**应对**:手写 `migrations.RenameField`。
|
||||
- **数据迁移边界**:`Factory.short_name` 为空 / None 时跳过;多个 Factory 相同 short_name 时 `get_or_create` 正确;Material 无 factory 时 brand 留空。
|
||||
- **前端 FactoryManage 对 brand 的遗留引用**:rename 时前端所有 `brand` 引用都要同步改,grep 检查避免漏网。
|
||||
- **品牌删除 PROTECT 错误**:DRF 默认 `ProtectedError` 抛 500。**应对**:viewset 的 `destroy` 捕获并返回 400 + 中文提示。
|
||||
- **回滚路径**:顺序反向 `migrate` 即可(material → factory → brand zero)。`backwards` 保持 no-op。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- **迁移正确性**(在本地库上跑一次并验证):
|
||||
- Brand 记录数 = Factory.short_name 非空唯一值数
|
||||
- 所有 factory 有 short_name 的 Material,brand 非空
|
||||
- 同名 Factory.short_name 不会生成重复 Brand
|
||||
- **品牌库 CRUD 手工测试**:新增 / 编辑 / 搜索 / 分页 / 删除(有无引用两种情况)
|
||||
- **材料页**:品牌列显示 / 筛选生效 / 表单下拉可用 / 必填校验生效 / 提交成功
|
||||
- **Factory 重命名**:前端 FactoryManage 正常工作,无遗留 `brand` 引用
|
||||
- **权限**:非 admin 无法访问 `/brands` 路由,无法看到菜单项
|
||||
Loading…
Reference in New Issue