Ver código fonte

feat(web-admin): choose display name and brand constraint on quote/contract lines

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 meses atrás
pai
commit
cd33befa07

+ 6 - 0
web-admin/src/api/crm/contract/types.ts

@@ -62,6 +62,12 @@ export interface ContractProductForm {
   remark?: string
   isToxic?: number
   isExplosive?: number
+  brandMode?: string
+  brandId?: string
+  brandName?: string
+  standardProductName?: string
+  nameOptions?: string[]
+  brandOptions?: Array<{ id: string; brandName: string }>
   priceTiers?: ContractProductPriceTierForm[]
 }
 

+ 8 - 0
web-admin/src/api/crm/quote/types.ts

@@ -47,6 +47,14 @@ export interface QuoteProductForm {
   remark?: string
   isToxic?: number
   isExplosive?: number
+  /** required=指定品牌 any=不限品牌;无品牌产品不传 */
+  brandMode?: string
+  brandId?: string
+  brandName?: string
+  /** 以下仅前端编辑态使用,提交时可一并带上(后端忽略未知字段) */
+  standardProductName?: string
+  nameOptions?: string[]
+  brandOptions?: Array<{ id: string; brandName: string }>
 }
 
 export interface QuoteForm {

+ 70 - 0
web-admin/src/utils/crm-product-brand.ts

@@ -0,0 +1,70 @@
+/** CRM 产品行:展示名 / 品牌约定(报价、合同共用) */
+
+export type BrandOption = { id: string; brandName: string }
+
+export function buildProductNameOptions(
+  standardName?: string | null,
+  aliases?: Array<{ aliasName?: string } | null> | null
+): string[] {
+  const names = [String(standardName || '').trim()]
+  for (const a of aliases || []) {
+    const n = String(a?.aliasName || '').trim()
+    if (n) names.push(n)
+  }
+  return [...new Set(names.filter(Boolean))]
+}
+
+export function formatBrandConstraintLabel(
+  brandMode?: string | null,
+  brandName?: string | null
+): string {
+  if (!brandMode) return '—'
+  if (brandMode === 'required') return String(brandName || '').trim() || '—'
+  if (brandMode === 'any') return '不限品牌'
+  return '—'
+}
+
+export function initLineBrandMetaFromProduct(product: {
+  productName?: string
+  brands?: Array<{ id?: string; brandName?: string; status?: string }>
+  aliases?: Array<{ aliasName?: string }>
+}) {
+  const brands = (product.brands || []).filter((b) => (b.status || 'on') === 'on' && b.id && b.brandName)
+  const standardProductName = String(product.productName || '').trim()
+  return {
+    standardProductName,
+    nameOptions: buildProductNameOptions(standardProductName, product.aliases),
+    brandOptions: brands.map((b) => ({
+      id: String(b.id),
+      brandName: String(b.brandName)
+    })) as BrandOption[],
+    brandMode: brands.length ? ('any' as const) : undefined,
+    brandId: undefined as string | undefined,
+    brandName: undefined as string | undefined
+  }
+}
+
+export function applyBrandModeChange(
+  row: {
+    brandMode?: string
+    brandId?: string
+    brandName?: string
+  },
+  mode: string | undefined
+) {
+  row.brandMode = mode
+  if (mode !== 'required') {
+    row.brandId = undefined
+    row.brandName = undefined
+  }
+}
+
+export function applyBrandSelectChange(
+  row: { brandId?: string; brandName?: string },
+  brandId: string | undefined,
+  options: BrandOption[]
+) {
+  row.brandId = brandId
+  const hit = options.find((o) => o.id === brandId)
+  row.brandName = hit?.brandName
+}

+ 6 - 0
web-admin/src/views/crm/contract/components/ContractDetailDialog.vue

@@ -183,6 +183,11 @@
                   <el-table :data="detailContract.products" class="list-table" stripe empty-text="暂无产品明细">
                 <el-table-column label="序号" type="index" min-width="60" align="center" header-align="center" />
                 <el-table-column label="产品名称" prop="productName" min-width="120" :show-overflow-tooltip="true" />
+                <el-table-column label="品牌" min-width="100" :show-overflow-tooltip="true">
+                  <template #default="{ row }">
+                    <span>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
+                  </template>
+                </el-table-column>
                 <el-table-column label="型号/规格" prop="model" min-width="130" :show-overflow-tooltip="true">
                   <template #default="scope">
                     <span>{{ scope.row.model || scope.row.productModel || '-' }}</span>
@@ -877,6 +882,7 @@ import { getOpportunity } from '@/api/crm/opportunity';
 import { useDictStore } from '@/store/modules/dict';
 import DictTag from '@/components/DictTag/index.vue';
 import { $modal } from '@/utils/modal';
+import { formatBrandConstraintLabel } from '@/utils/crm-product-brand'
 import { parseTime } from '@/utils/format';
 import { useContractPerm } from '@/hooks/business/useContractPerm';
 import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete';

+ 134 - 3
web-admin/src/views/crm/contract/components/ContractFormDialog.vue

@@ -247,7 +247,68 @@
             <el-table-column label="序号" type="index" min-width="60" align="center" header-align="center" />
             <el-table-column label="产品名称" prop="productName" min-width="160" :show-overflow-tooltip="true">
               <template #default="scope">
-                <el-input v-model="scope.row.productName" placeholder="请输入产品名称" clearable size="small" readonly />
+                <el-select
+                  v-if="(scope.row.nameOptions || []).length > 1"
+                  v-model="scope.row.productName"
+                  placeholder="选择展示名称"
+                  filterable
+                  allow-create
+                  default-first-option
+                  size="small"
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="name in scope.row.nameOptions"
+                    :key="name"
+                    :label="name"
+                    :value="name"
+                  />
+                </el-select>
+                <el-input
+                  v-else
+                  v-model="scope.row.productName"
+                  placeholder="请输入产品名称"
+                  clearable
+                  size="small"
+                  readonly
+                />
+              </template>
+            </el-table-column>
+            <el-table-column label="品牌要求" min-width="120">
+              <template #default="scope">
+                <el-select
+                  v-if="(scope.row.brandOptions || []).length"
+                  v-model="scope.row.brandMode"
+                  placeholder="品牌要求"
+                  size="small"
+                  style="width: 100%"
+                  @change="(val) => handleBrandModeChange(scope.row, val)"
+                >
+                  <el-option label="不限品牌" value="any" />
+                  <el-option label="指定品牌" value="required" />
+                </el-select>
+                <span v-else class="text-muted">—</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="品牌" min-width="120">
+              <template #default="scope">
+                <el-select
+                  v-if="scope.row.brandMode === 'required' && (scope.row.brandOptions || []).length"
+                  v-model="scope.row.brandId"
+                  placeholder="选择品牌"
+                  size="small"
+                  style="width: 100%"
+                  @change="(val) => handleBrandIdChange(scope.row, val)"
+                >
+                  <el-option
+                    v-for="b in scope.row.brandOptions"
+                    :key="b.id"
+                    :label="b.brandName"
+                    :value="b.id"
+                  />
+                </el-select>
+                <span v-else-if="scope.row.brandMode === 'any'">不限</span>
+                <span v-else class="text-muted">—</span>
               </template>
             </el-table-column>
             <el-table-column label="型号/规格" prop="productModel" min-width="130" :show-overflow-tooltip="true">
@@ -546,6 +607,13 @@ import CustomerSelector from '@/components/CustomerSelector.vue'
 import ContactSelector from '@/components/ContactSelector.vue'
 import QuoteSelector from '@/components/QuoteSelector.vue'
 import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
+import {
+  applyBrandModeChange,
+  applyBrandSelectChange,
+  buildProductNameOptions,
+  initLineBrandMetaFromProduct
+} from '@/utils/crm-product-brand'
+import { fetchProductDetail } from '@/api/crm/product'
 import ApprovalDialog from './ApprovalDialog.vue'
 import { getContactByCustomerId } from '@/api/crm/customer'
 import type { CustomerVO, ContactVO } from '@/api/crm/customer/types'
@@ -890,6 +958,11 @@ const loadContractDetail = async (id: number | string) => {
       productModel: product.model || '',
       isToxic: Number(product.isToxic || 0),
       isExplosive: Number(product.isExplosive || 0),
+      brandMode: product.brandMode || undefined,
+      brandId: product.brandId || undefined,
+      brandName: product.brandName || undefined,
+      nameOptions: product.productName ? [product.productName] : [],
+      brandOptions: [],
       priceTiers: (product.priceTiers || []).map((t: any) => {
         let ids: string[] = []
         if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
@@ -909,6 +982,8 @@ const loadContractDetail = async (id: number | string) => {
       deliveryMethod: product.deliveryMethod || ''
     }))
 
+    await enrichContractProductBrandMeta(form.value.products)
+
     if (contractData.contractType === 'framework') {
       form.value.products.forEach((product: any) => {
         product.price = 0
@@ -1139,7 +1214,7 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
     if (quote.productList && quote.productList.length > 0) {
       $modal
         .confirm('是否要从报价单中带入产品明细?')
-        .then(() => {
+        .then(async () => {
           const isFw = form.value.contractType === 'framework'
           form.value.products = quote.productList!.map((item: any) => ({
             productId: item.productId,
@@ -1161,8 +1236,14 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
             description: item.description || '',
             remark: item.remark || '',
             sort: item.sort,
+            brandMode: item.brandMode || undefined,
+            brandId: item.brandId || undefined,
+            brandName: item.brandName || undefined,
+            nameOptions: item.productName ? [item.productName] : [],
+            brandOptions: [],
             priceTiers: isFw ? [] : undefined
           }))
+          await enrichContractProductBrandMeta(form.value.products)
           calculateTotalAmount()
         })
         .catch(() => {})
@@ -1490,6 +1571,7 @@ const handleAddProduct = () => {
 const handleProductSelected = (products: any[]) => {
   const isFw = form.value.contractType === 'framework'
   products.forEach((product) => {
+    const brandMeta = initLineBrandMetaFromProduct(product)
     form.value.products.push({
       productId: product.id,
       productName: product.productName,
@@ -1508,6 +1590,7 @@ const handleProductSelected = (products: any[]) => {
       remark: '',
       isToxic: Number(product.isToxic || 0),
       isExplosive: Number(product.isExplosive || 0),
+      ...brandMeta,
       priceTiers: isFw ? [] : undefined
     })
   })
@@ -1517,6 +1600,43 @@ const handleProductSelected = (products: any[]) => {
   calculateTotalAmount()
 }
 
+const handleBrandModeChange = (row: ExtendedContractProductForm, val: string) => {
+  applyBrandModeChange(row, val)
+}
+
+const handleBrandIdChange = (row: ExtendedContractProductForm, val: string) => {
+  applyBrandSelectChange(row, val, row.brandOptions || [])
+}
+
+async function enrichContractProductBrandMeta(lines: ExtendedContractProductForm[]) {
+  await Promise.all(
+    (lines || []).map(async (line) => {
+      if (!line.productId) return
+      try {
+        const res: any = await fetchProductDetail({ id: String(line.productId) })
+        const data = res?.data ?? res
+        if (!data) return
+        const meta = initLineBrandMetaFromProduct(data)
+        line.standardProductName = meta.standardProductName
+        line.nameOptions = buildProductNameOptions(meta.standardProductName, data.aliases)
+        if (line.productName && !line.nameOptions.includes(String(line.productName))) {
+          line.nameOptions = [...line.nameOptions, String(line.productName)]
+        }
+        line.brandOptions = meta.brandOptions
+        if (meta.brandOptions.length) {
+          if (!line.brandMode) line.brandMode = 'any'
+        } else {
+          line.brandMode = undefined
+          line.brandId = undefined
+          line.brandName = undefined
+        }
+      } catch (e) {
+        console.warn('enrich contract brand meta failed', line.productId, e)
+      }
+    })
+  )
+}
+
 /** 规范化税率展示 */
 const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
   if (value === null || value === undefined || value === '') {
@@ -1685,7 +1805,10 @@ const buildProductsPayload = () => {
       packageSpec: product.packageSpec,
       deliveryMethod: product.deliveryMethod,
       sort: product.sort ?? idx + 1,
-      remark: product.remark
+      remark: product.remark,
+      brandMode: product.brandMode || null,
+      brandId: product.brandMode === 'required' ? product.brandId || null : null,
+      brandName: product.brandMode === 'required' ? product.brandName || null : null
     }
     if (product.id) {
       base.id = product.id
@@ -1839,6 +1962,10 @@ const handleSaveDraft = async () => {
           $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
           return
         }
+        if ((product.brandOptions || []).length && product.brandMode === 'required' && !product.brandId) {
+          $modal.msgWarning(`第${rowNum}行产品请选择指定品牌`)
+          return
+        }
       }
       data.products = buildProductsPayload()
     } else {
@@ -1890,6 +2017,10 @@ const handleSubmit = async () => {
       $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
       return false
     }
+    if ((product.brandOptions || []).length && product.brandMode === 'required' && !product.brandId) {
+      $modal.msgWarning(`第${rowNum}行产品请选择指定品牌`)
+      return false
+    }
     if (!product.quantity || Number(product.quantity) <= 0) {
       $modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`)
       return false

+ 6 - 0
web-admin/src/views/crm/quote/components/QuoteDetailDialog.vue

@@ -75,6 +75,11 @@
           >
             <el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
           <el-table-column label="产品名称" prop="productName" min-width="120" align="center" header-align="center" show-overflow-tooltip />
+          <el-table-column label="品牌" min-width="100" align="center" header-align="center" show-overflow-tooltip>
+            <template #default="{ row }">
+              <span>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="型号/规格" prop="model" min-width="130" align="center" header-align="center" show-overflow-tooltip>
             <template #default="{ row }">
               <span>{{ row.model || '—' }}</span>
@@ -318,6 +323,7 @@ import { useDictStore } from '@/store/modules/dict'
 import { useQuotePerm } from '@/hooks/business/useQuotePerm'
 import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
 import { $modal } from '@/utils/modal'
+import { formatBrandConstraintLabel } from '@/utils/crm-product-brand'
 import { parseTime } from '@/utils/format'
 import {
   getQuote,

+ 130 - 8
web-admin/src/views/crm/quote/components/QuoteFormPanel.vue

@@ -131,7 +131,67 @@
             <el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
             <el-table-column label="产品名称" prop="productName" min-width="160" :show-overflow-tooltip="true">
               <template #default="scope">
-                <el-input v-model="scope.row.productName" placeholder="请输入产品名称" clearable readonly />
+                <el-select
+                  v-if="(scope.row.nameOptions || []).length > 1"
+                  v-model="scope.row.productName"
+                  placeholder="选择展示名称"
+                  filterable
+                  allow-create
+                  default-first-option
+                  size="small"
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="name in scope.row.nameOptions"
+                    :key="name"
+                    :label="name"
+                    :value="name"
+                  />
+                </el-select>
+                <el-input
+                  v-else
+                  v-model="scope.row.productName"
+                  placeholder="请输入产品名称"
+                  clearable
+                  readonly
+                />
+              </template>
+            </el-table-column>
+            <el-table-column label="品牌要求" min-width="120">
+              <template #default="scope">
+                <el-select
+                  v-if="(scope.row.brandOptions || []).length"
+                  v-model="scope.row.brandMode"
+                  placeholder="品牌要求"
+                  size="small"
+                  style="width: 100%"
+                  @change="(val) => handleBrandModeChange(scope.row, val)"
+                >
+                  <el-option label="不限品牌" value="any" />
+                  <el-option label="指定品牌" value="required" />
+                </el-select>
+                <span v-else class="text-muted">—</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="品牌" min-width="120">
+              <template #default="scope">
+                <el-select
+                  v-if="scope.row.brandMode === 'required' && (scope.row.brandOptions || []).length"
+                  v-model="scope.row.brandId"
+                  placeholder="选择品牌"
+                  size="small"
+                  style="width: 100%"
+                  @change="(val) => handleBrandIdChange(scope.row, val)"
+                >
+                  <el-option
+                    v-for="b in scope.row.brandOptions"
+                    :key="b.id"
+                    :label="b.brandName"
+                    :value="b.id"
+                  />
+                </el-select>
+                <span v-else-if="scope.row.brandMode === 'any'">不限</span>
+                <span v-else class="text-muted">—</span>
               </template>
             </el-table-column>
             <el-table-column label="型号/规格" prop="model" min-width="130" :show-overflow-tooltip="true">
@@ -477,6 +537,13 @@ import { fetchUserOptions } from '@/api/admin';
 import { fetchContactsByCustomerId } from '@/api/crm/contact';
 import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
 import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
+import {
+  applyBrandModeChange,
+  applyBrandSelectChange,
+  buildProductNameOptions,
+  initLineBrandMetaFromProduct
+} from '@/utils/crm-product-brand'
+import { fetchProductDetail } from '@/api/crm/product'
 
 const props = withDefaults(
   defineProps<{
@@ -797,8 +864,14 @@ const getQuoteInfo = async (id: string | number) => {
         deliveryMethod: item.deliveryMethod || '', // 送货方式
         description: item.description,
         isToxic: Number(item.isToxic || 0),
-        isExplosive: Number(item.isExplosive || 0)
-      }));
+        isExplosive: Number(item.isExplosive || 0),
+        brandMode: item.brandMode || undefined,
+        brandId: item.brandId || undefined,
+        brandName: item.brandName || undefined,
+        nameOptions: item.productName ? [item.productName] : [],
+        brandOptions: []
+      }))
+      await enrichQuoteProductBrandMeta(form.value.products)
     }
 
     // 加载附件数据
@@ -1163,6 +1236,7 @@ const handleAddProduct = () => {
 /** 产品选择确认 */
 const handleProductConfirm = (products: any[]) => {
   products.forEach((product) => {
+    const brandMeta = initLineBrandMetaFromProduct(product)
     // 允许重复添加相同产品:直接追加到 products 列表
     const productForm: QuoteProductForm = {
       productId: product.id,
@@ -1179,11 +1253,49 @@ const handleProductConfirm = (products: any[]) => {
       packageSpec: '',
       deliveryMethod: '',
       isToxic: Number(product.isToxic || 0),
-      isExplosive: Number(product.isExplosive || 0)
-    };
-    form.value.products?.push(productForm);
-  });
-};
+      isExplosive: Number(product.isExplosive || 0),
+      ...brandMeta
+    }
+    form.value.products?.push(productForm)
+  })
+}
+
+const handleBrandModeChange = (row: QuoteProductForm, val: string) => {
+  applyBrandModeChange(row, val)
+}
+
+const handleBrandIdChange = (row: QuoteProductForm, val: string) => {
+  applyBrandSelectChange(row, val, row.brandOptions || [])
+}
+
+async function enrichQuoteProductBrandMeta(lines: QuoteProductForm[]) {
+  await Promise.all(
+    (lines || []).map(async (line) => {
+      if (!line.productId) return
+      try {
+        const res: any = await fetchProductDetail({ id: String(line.productId) })
+        const data = res?.data ?? res
+        if (!data) return
+        const meta = initLineBrandMetaFromProduct(data)
+        line.standardProductName = meta.standardProductName
+        line.nameOptions = buildProductNameOptions(meta.standardProductName, data.aliases)
+        if (line.productName && !line.nameOptions.includes(line.productName)) {
+          line.nameOptions = [...line.nameOptions, line.productName]
+        }
+        line.brandOptions = meta.brandOptions
+        if (meta.brandOptions.length) {
+          if (!line.brandMode) line.brandMode = 'any'
+        } else {
+          line.brandMode = undefined
+          line.brandId = undefined
+          line.brandName = undefined
+        }
+      } catch (e) {
+        console.warn('enrich brand meta failed', line.productId, e)
+      }
+    })
+  )
+}
 
 /** 规范化税率展示:整数不带小数,有小数才保留 */
 const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
@@ -1447,6 +1559,11 @@ const handleSaveDraft = () => {
             return;
           }
 
+          if ((product.brandOptions || []).length && product.brandMode === 'required' && !product.brandId) {
+            $modal.msgWarning(`第${rowNum}行产品请选择指定品牌`);
+            return;
+          }
+
           if (!product.quantity || product.quantity <= 0) {
             $modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
             return;
@@ -1551,6 +1668,11 @@ const handleSubmit = async () => {
       return;
     }
 
+    if ((product.brandOptions || []).length && product.brandMode === 'required' && !product.brandId) {
+      $modal.msgWarning(`第${rowNum}行产品请选择指定品牌`);
+      return;
+    }
+
     if (!product.quantity || product.quantity <= 0) {
       $modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
       return;