diff --git a/docs/superpowers/specs/2026-04-23-brand-entity-design.md b/docs/superpowers/specs/2026-04-23-brand-entity-design.md index 7ebf580..000305d 100644 --- a/docs/superpowers/specs/2026-04-23-brand-entity-design.md +++ b/docs/superpowers/specs/2026-04-23-brand-entity-design.md @@ -39,7 +39,7 @@ | 数据迁移 | 自动迁移(从 Factory.short_name 唯一值自动建 Brand 并回填 Material.brand) | 历史数据一步到位 | | 迁移颗粒度 | 单次大迁移(schema + data 合并发布) | 数据量小,一次到位更省事 | | 菜单位置 | 顶层一级菜单"品牌库"(供应商库与材料管理之间) | 作为核心业务数据,位置显眼 | -| 权限 | 仅 admin 可见可编辑 | 品牌是管理员维护的主数据 | +| 权限 | 管理界面(品牌库菜单+CRUD 页面)仅 admin;读接口对所有已认证用户开放(供材料表单下拉读取) | 品牌是管理员维护的主数据,但非 admin 需要在录入材料时选择品牌 | | 品牌删除行为 | PROTECT,前端 catch 错误并提示 | 防止误删丢关联 | | 品牌库搜索 | 仅按 name 模糊搜索 | description 语义不适合搜索 | | Material 列表 | 增加"品牌"列 + 品牌筛选器 | 与现有 factory 筛选保持一致 | @@ -117,15 +117,17 @@ brand = models.ForeignKey( ### 迁移顺序 +仓库现有 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/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) +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/0003_populate_brand.py +# material/migrations/0008_populate_brand.py from django.db import migrations def forwards(apps, schema_editor): @@ -134,17 +136,19 @@ def forwards(apps, schema_editor): 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) + 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 - 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']) + # 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 字段,这里无需操作 @@ -152,8 +156,8 @@ def backwards(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('material', '0002_add_brand_fk'), - ('factory', '0002_rename_brand_short_name'), + ('material', '0007_add_brand_fk'), + ('factory', '0004_rename_brand_to_short_name'), ('brand', '0001_initial'), ] operations = [migrations.RunPython(forwards, backwards)] @@ -161,13 +165,13 @@ class Migration(migrations.Migration): ### API 端点 -`/api/brand/`(仅 admin 写,读可对所有登录用户开放,但菜单只 admin 可见): +`/api/brand/` 权限约定:**所有已认证用户可读,仅 admin 可写**(非 admin 用户在材料表单的品牌下拉需要读这个接口,所以不能整体 admin-only)。"决策总表"里的"仅 admin 可见可编辑"特指前端**菜单与管理界面**的可见/编辑权限,不影响读接口。 -- `GET /api/brand/` — 列表,支持 `search=` 模糊搜索与分页 -- `POST /api/brand/` — 创建 -- `GET /api/brand/{id}/` — 详情 -- `PUT /api/brand/{id}/` — 更新 -- `DELETE /api/brand/{id}/` — 删除;若被 Material 引用则 PROTECT 报错,viewset 需捕获 `ProtectedError` 返回 400 + 中文提示 +- `GET /api/brand/` — 列表,支持 `search=` 模糊搜索与分页(已认证用户) +- `POST /api/brand/` — 创建(admin) +- `GET /api/brand/{id}/` — 详情(已认证用户) +- `PUT /api/brand/{id}/` — 更新(admin) +- `DELETE /api/brand/{id}/` — 删除(admin);若被 Material 引用则 PROTECT 报错,viewset 需捕获 `ProtectedError` 返回 400 + 中文提示 Material 现有接口调整: @@ -259,7 +263,7 @@ Factory 接口调整: - **Django rename migration 的交互坑**:`makemigrations` 会交互询问是否重命名,自动化环境不友好。**应对**:手写 `migrations.RenameField`。 - **数据迁移边界**:`Factory.short_name` 为空 / None 时跳过;多个 Factory 相同 short_name 时 `get_or_create` 正确;Material 无 factory 时 brand 留空。 -- **前端 FactoryManage 对 brand 的遗留引用**:rename 时前端所有 `brand` 引用都要同步改,grep 检查避免漏网。 +- **前端 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。