12 KiB
品牌(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 APIfactory:模型字段brand→short_name(schema migration)material:新增brand = ForeignKey(Brand, …)字段 + 跨 app 的数据迁移
前端新增:/brands 路由 + BrandManage.vue 视图 + api/brand.js,菜单加一项"品牌库"(admin 可见);修改 MaterialManage.vue 增加品牌列 / 筛选器 / 表单下拉;同步改 FactoryManage.vue 文案。
后端设计
Brand 模型
# 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 字段重命名
# 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 新增外键
# 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 编号如下:
brand/migrations/0001_initial.py— 建 Brand 表factory/migrations/0004_rename_brand_to_short_name.py— 重命名字段(手写migrations.RenameField,避免依赖 makemigrations 的交互式识别;dependencies 指向('factory', '0003_rename_factory_short_name_to_brand'))material/migrations/0007_add_brand_fk.py— 新增 Material.brand 外键(依赖brand.0001与material.0006_alter_material_options_and_more)material/migrations/0008_populate_brand.py— 数据迁移(RunPython,依赖factory.0004、material.0007、brand.0001)
数据迁移脚本
# 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/新增brandquery param(按品牌 id 过滤)- Material serializer 增加
brand字段(读:嵌套{id, name};写:接受 id)
Factory 接口调整:
- serializer / filterset / ordering 中所有
brand引用同步改为short_name
前端设计
新增文件
frontend/src/api/brand.js— 封装listBrands/createBrand/updateBrand/deleteBrandfrontend/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确保无遗漏
实施步骤
严格按以下顺序执行,便于单步验证:
-
后端模型与迁移
- 创建
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_branddata migration - 本地
python manage.py migrate验证
- 创建
-
后端 API & serializer
- Brand CRUD(admin 权限)
- Material serializer 增加 brand 字段;filterset 增加 brand
- Factory serializer / view 同步把 brand → short_name
-
前端品牌库
api/brand.js+BrandManage.vue+ 路由 + 菜单
-
前端材料页面改造
- MaterialManage 列表列 / 筛选器 / 表单下拉
- MaterialDetail 显示品牌
- FactoryManage 文案同步
-
联调验证
- 品牌库看到从 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-950CSS 变量,是设计 token 命名,与业务字段无关,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路由,无法看到菜单项