mat/docs/superpowers/specs/2026-04-23-brand-entity-des...

280 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 品牌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 合并发布) | 数据量小,一次到位更省事 |
| 菜单位置 | 顶层一级菜单"品牌库"(供应商库与材料管理之间) | 作为核心业务数据,位置显眼 |
| 权限 | 管理界面(品牌库菜单+CRUD 页面)仅 admin读接口对所有已认证用户开放供材料表单下拉读取 | 品牌是管理员维护的主数据,但非 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='品牌',
)
```
### 迁移顺序
仓库现有 migration 最新状态:`factory/0003_rename_factory_short_name_to_brand.py`(此前字段原名 `factory_short_name`,在 0003 被 rename 为 `brand`;本次是反向 rename恢复为 `short_name` 以消除歧义),`material/0006_alter_material_options_and_more.py`。因此本次新增 migration 编号如下:
1. `brand/migrations/0001_initial.py` — 建 Brand 表
2. `factory/migrations/0004_rename_brand_to_short_name.py` — 重命名字段(手写 `migrations.RenameField`,避免依赖 makemigrations 的交互式识别dependencies 指向 `('factory', '0003_rename_factory_short_name_to_brand')`
3. `material/migrations/0007_add_brand_fk.py` — 新增 Material.brand 外键(依赖 `brand.0001``material.0006_alter_material_options_and_more`
4. `material/migrations/0008_populate_brand.py` — 数据迁移(`RunPython`,依赖 `factory.0004`、`material.0007`、`brand.0001`
### 数据迁移脚本
```python
# material/migrations/0008_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
short_names = (
Factory.objects
.exclude(short_name__isnull=True)
.exclude(short_name='')
.values_list('short_name', flat=True)
.distinct()
)
for sn in short_names:
Brand.objects.get_or_create(name=sn)
# 2) 回填 Material.brand按品牌批量 update避免逐条 save
for brand in Brand.objects.all():
Material.objects.filter(factory__short_name=brand.name).update(brand=brand)
def backwards(apps, schema_editor):
# schema migration 回滚时会删掉 Material.brand 字段,这里无需操作
pass
class Migration(migrations.Migration):
dependencies = [
('material', '0007_add_brand_fk'),
('factory', '0004_rename_brand_to_short_name'),
('brand', '0001_initial'),
]
operations = [migrations.RunPython(forwards, backwards)]
```
### API 端点
`/api/brand/` 权限约定:**所有已认证用户可读,仅 admin 可写**(非 admin 用户在材料表单的品牌下拉需要读这个接口,所以不能整体 admin-only。"决策总表"里的"仅 admin 可见可编辑"特指前端**菜单与管理界面**的可见/编辑权限,不影响读接口。
- `GET /api/brand/` — 列表,支持 `search=<name>` 模糊搜索与分页(已认证用户)
- `POST /api/brand/` — 创建admin
- `GET /api/brand/{id}/` — 详情(已认证用户)
- `PUT /api/brand/{id}/` — 更新admin
- `DELETE /api/brand/{id}/` — 删除admin若被 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后端返回 400PROTECT 触发)时 catch 并提示"该品牌下存在材料,无法删除"
### MaterialManage.vue 改动
- 列表页:
- 新增"品牌"列,展示 `row.brand?.name`
- 筛选区新增"品牌"下拉(数据从 `/api/brand/` 拉取,支持远程搜索)
- 新增 / 编辑弹窗表单:
- 增加"品牌"字段,`el-select` 远程搜索,前端标记 `required`
- 位置紧挨"供应商"字段后
- 提交时把 brandid纳入 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 CRUDadmin 权限)
- 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 检查避免漏网。注意 `frontend/src/styles/base.css``MainLayout.vue``--brand-900` / `--brand-950` CSS 变量,是设计 token 命名与业务字段无关grep 时需排除。
- **品牌删除 PROTECT 错误**DRF 默认 `ProtectedError` 抛 500。**应对**viewset 的 `destroy` 捕获并返回 400 + 中文提示。
- **回滚路径**:顺序反向 `migrate` 即可material → factory → brand zero。`backwards` 保持 no-op。
## 测试计划
- **迁移正确性**(在本地库上跑一次并验证):
- Brand 记录数 = Factory.short_name 非空唯一值数
- 所有 factory 有 short_name 的 Materialbrand 非空
- 同名 Factory.short_name 不会生成重复 Brand
- **品牌库 CRUD 手工测试**:新增 / 编辑 / 搜索 / 分页 / 删除(有无引用两种情况)
- **材料页**:品牌列显示 / 筛选生效 / 表单下拉可用 / 必填校验生效 / 提交成功
- **Factory 重命名**:前端 FactoryManage 正常工作,无遗留 `brand` 引用
- **权限**:非 admin 无法访问 `/brands` 路由,无法看到菜单项