diff --git a/docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md b/docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md new file mode 100644 index 000000000..42331197e --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-quote-contract-alias-brand-manage-design.md @@ -0,0 +1,127 @@ +# 报价/合同明细:别名与品牌就地维护弹窗 + +**日期:** 2026-08-07 +**状态:** 待实现 +**选定方案:** 抽公共轻量维护弹窗;写回 CRM 产品主数据;弹窗内即时保存;报价单 + 合同同步接入 +**前置:** + +- `docs/superpowers/specs/2026-07-27-crm-product-brand-alias-design.md`(品牌/别名主数据) +- `docs/superpowers/specs/2026-07-30-quote-contract-brand-alias-ux-design.md`(明细行列交互:下拉 / `—`) +- `docs/superpowers/specs/2026-08-07-crm-product-selector-enhance-design.md`(选择器内可打开完整产品编辑;本需求为明细行旁的轻量入口,不替代完整产品表单) + +## 1. 目标 + +在报价单、合同的新增/编辑「产品明细」中,别名列与品牌列旁增加 icon 按钮;点击弹出维护弹窗,对当前产品的别名或品牌做新增、编辑、删除;保存后写回产品主数据,并立即刷新明细行下拉选项,开单过程中无需跳转产品管理页。 + +## 2. 非目标 + +- 不新增后端接口、不新增库表/字段 +- 不把别名/品牌做成报价行/合同行私有数据 +- 不在只读查看弹窗、列表展开子表、打印模板中增加维护入口 +- 不改 `contract/form.vue`(当前无别名/品牌明细列) +- 不打开完整 `ProductFormDialog`(避免误改价格、分类等);本需求仅维护别名或品牌子集 +- 不做乐观锁/并发合并(与现有产品编辑一致:后写覆盖) + +## 3. 已确认决策 + +| 决策点 | 结论 | +|--------|------| +| 数据归属 | 写回产品主数据(`crm_product_alias` / `crm_product_brand`) | +| 保存时机 | 弹窗内点确定 → 立即 `fetchUpdateProduct` → 成功后刷新下拉 | +| 范围 | 报价单 + 合同一起做 | +| 权限 | 能编辑报价单/合同即可点维护;不单独按产品菜单权限藏按钮;接口无权限时提示错误 | +| 实现路径 | 公共组件 + 复用 `products/detail`、`products/update` | + +## 4. 架构 + +``` +web-admin/src/components/business/ + product-alias-brand-dialog/index.vue # 新增:别名或品牌轻量维护弹窗 + +web-admin/src/views/crm/quote/components/QuoteFormPanel.vue + → 别名/品牌列旁 icon;打开弹窗;saved 后刷新同 productId 行 options + +web-admin/src/views/crm/contract/components/ContractFormDialog.vue + → 同上 +``` + +### 4.1 ProductAliasBrandDialog + +| 项 | 约定 | +|----|------| +| Props | `visible`(或 `v-model`)、`productId: string`、`mode: 'alias' \| 'brand'`、可选 `productName`(标题展示) | +| Emits | `update:visible`、`saved`(payload:更新后的产品详情或 `aliases`/`brands`,便于父组件重建 options) | +| 打开 | `fetchProductDetail({ id })` 填充对应列表;保留项上 `id` 以便更新替换 | +| 提交 | `fetchUpdateProduct({ id, aliases })` 或 `{ id, brands }`,**不传**其他产品字段(后端仅在 kwargs 含 `brands`/`aliases` 时分别 `_replace_*`) | +| UI | `dialog-form-layout`,宽约 560~640px;标题 `维护别名 - {产品名}` / `维护品牌 - {产品名}`;底栏 Close/Check | + +列表交互对齐产品表单子卡片: + +- **别名**:名称输入 + 行删;顶栏「新增别名」 +- **品牌**:名称 + 状态(`crm_product_status`,默认 `on`)+ 行删;顶栏「新增品牌」 +- 空态:`暂无别名/品牌,点击上方新增` + +### 4.2 明细列布局(报价 + 合同) + +单元格横向: + +``` +[ el-select(有 options)| 灰色 —(无 options) ] [ link icon 按钮 ] +``` + +- icon:`Setting` 或 `Edit`(Element Plus),`link`/`text` 小按钮 +- **无论是否已有 options 都显示** icon(无数据时用于新增) +- 仅当行有 `productId` 且处于可编辑表单时显示 +- 下拉选项规则不变:仅上架(`on`)品牌进入 `brandOptions`(`initLineBrandMetaFromProduct`) + +## 5. 校验与错误处理 + +弹窗确定前: + +- 名称必填,trim 后非空 +- 同列表内名称不可重复 +- 品牌须有状态 + +提交失败: + +- 保留弹窗与编辑内容 +- 展示后端错误(含品牌仍有库存 / 被未归档计划引用无法删除等) + +行内删除:先本地移除,点弹窗「确定」才落库;取消/关闭不落库。 + +## 6. 父组件刷新规则 + +维护成功后,对当前表单中 **相同 `productId` 的所有行**: + +| 场景 | 行为 | +|------|------| +| 别名 | 更新 `aliasOptions`;当前 `aliasName` 不在新列表 → 清空 | +| 品牌 | `initLineBrandMetaFromProduct` 重建 `brandOptions`;当前 `brandId` 不在上架选项 → `brandMode=any`,清空 `brandId`/`brandName` | +| 从无到有 | `—` → 下拉,可立即选择 | +| 从有到无 | 下拉 → `—`,并清空该行已选别名/品牌 | + +**不**自动把新建项选中到当前行,避免误改已填内容。 + +## 7. 验收要点 + +1. 无别名/品牌时显示 `—` + icon;新增保存后下拉出现并可选用。 +2. 已有选项时 icon 可增删改;保存后 options 立即更新。 +3. 删除当前行已选别名/品牌并保存后,选择被清空或回退「不限品牌」。 +4. 同一产品多行时,一行维护成功后同 `productId` 各行 options 同步。 +5. 品牌删除受库存/计划约束时,弹窗保留并提示,主数据不变。 +6. 报价单与合同行为一致。 +7. 取消或关闭弹窗不落库。 + +## 8. 实现顺序 + +1. 新建 `product-alias-brand-dialog` +2. 接入 `QuoteFormPanel`(列 UI + saved 刷新) +3. 接入 `ContractFormDialog`(同上) +4. 按第 7 节手工验收 + +## 9. 相关代码锚点 + +- 明细列现状:`QuoteFormPanel.vue` / `ContractFormDialog.vue` 别名、品牌列(无 options 时 `—`) +- 选项构建:`web-admin/src/utils/crm-product-brand.ts`(`initLineBrandMetaFromProduct` 等) +- 产品子卡片参考:`web-admin/src/components/business/product-form-dialog/index.vue` +- 后端替换:`backend/app/crud/crm/product.py`(`_replace_product_brands` / `_replace_product_aliases`;品牌删除 `_assert_brand_deletable`)