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

12 KiB
Raw Permalink Blame History

品牌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 appbrand,与 factorymaterial 同级。

backend/apps/
  brand/                       # 新增
    __init__.py
    models.py                  # Brand 模型
    serializers.py
    views.py
    urls.py
    migrations/
      0001_initial.py

变更涉及三个后端 app

  • brand(新建):定义 Brand 模型、CRUD API
  • factory:模型字段 brandshort_nameschema 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 编号如下:

  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.0001material.0006_alter_material_options_and_more
  4. material/migrations/0008_populate_brand.py — 数据迁移(RunPython,依赖 factory.0004material.0007brand.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/ 新增 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.jsMainLayout 的 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.pyINSTALLED_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.cssMainLayout.vue--brand-900 / --brand-950 CSS 变量,是设计 token 命名与业务字段无关grep 时需排除。
  • 品牌删除 PROTECT 错误DRF 默认 ProtectedError 抛 500。应对viewset 的 destroy 捕获并返回 400 + 中文提示。
  • 回滚路径:顺序反向 migrate 即可material → factory → brand zerobackwards 保持 no-op。

测试计划

  • 迁移正确性(在本地库上跑一次并验证):
    • Brand 记录数 = Factory.short_name 非空唯一值数
    • 所有 factory 有 short_name 的 Materialbrand 非空
    • 同名 Factory.short_name 不会生成重复 Brand
  • 品牌库 CRUD 手工测试:新增 / 编辑 / 搜索 / 分页 / 删除(有无引用两种情况)
  • 材料页:品牌列显示 / 筛选生效 / 表单下拉可用 / 必填校验生效 / 提交成功
  • Factory 重命名:前端 FactoryManage 正常工作,无遗留 brand 引用
  • 权限:非 admin 无法访问 /brands 路由,无法看到菜单项