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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 09:30:38 +08:00
1 parent ba98108659
commit cd33befa07
7 files changed
+360 -11

No files matched your search

+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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';
@@ -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
@@ -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,
@@ -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;