feat(product-family): goods view family integration, family-based public variant union
This commit is contained in:
@@ -4,11 +4,10 @@ import { useVirtualList } from '@vueuse/core'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus, Edit, Delete, Search, Top, Refresh,
|
Plus, Edit, Delete, Search, Top, Refresh,
|
||||||
FolderAdd, Aim, ArrowDown, QuestionFilled,
|
FolderAdd, Aim, ArrowDown,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
|
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
|
||||||
MergedOriginGoodSummary,
|
|
||||||
OriginGoodsTreeResponse,
|
OriginGoodsTreeResponse,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { goodsApi } from '@/api/goods'
|
import { goodsApi } from '@/api/goods'
|
||||||
@@ -19,6 +18,8 @@ import { tagGroupsApi } from '@/api/tag-groups'
|
|||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
import { sameFamily } from '@/utils/family-match'
|
import { sameFamily } from '@/utils/family-match'
|
||||||
|
import { truncateToProcess } from '@/utils/origin-name'
|
||||||
|
import { productFamiliesApi } from '@/api/product-families'
|
||||||
|
|
||||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -256,14 +257,13 @@ const leftTreeData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function buildRightTree(tree: OriginGoodsTreeResponse) {
|
function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||||
function mapCat(node: any): any {
|
function mapOgs(ogs: any[], bucketId: string): any[] {
|
||||||
const children = (node.children || []).map(mapCat)
|
return ogs.map((og: any) => ({
|
||||||
const goods = (node.originGoods || []).map((og: any) => ({
|
|
||||||
id: 'og-' + og.id,
|
id: 'og-' + og.id,
|
||||||
label: og.goodName,
|
label: og.goodName,
|
||||||
isOG: true,
|
isOG: true,
|
||||||
rawId: og.id,
|
rawId: og.id,
|
||||||
parentId: 'rc-' + node.categoryId,
|
parentId: bucketId,
|
||||||
goodName: og.goodName,
|
goodName: og.goodName,
|
||||||
goodImage: og.goodImage,
|
goodImage: og.goodImage,
|
||||||
goodPrice: og.goodPrice,
|
goodPrice: og.goodPrice,
|
||||||
@@ -277,14 +277,59 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
packageRowCount: og.packageRowCount ?? 0,
|
packageRowCount: og.packageRowCount ?? 0,
|
||||||
familyId: og.familyId ?? null,
|
familyId: og.familyId ?? null,
|
||||||
familyCode: og.familyCode ?? null,
|
familyCode: og.familyCode ?? null,
|
||||||
|
familyName: og.familyName ?? null,
|
||||||
familyStale: og.familyStale ?? null,
|
familyStale: og.familyStale ?? null,
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类内按族分桶:族节点为父、链接为子;无族链接进「未入族」桶 */
|
||||||
|
function groupByFamily(ogs: any[], bucketPrefix: string): any[] {
|
||||||
|
const byFamily = new Map<string, { key: string; code: string | null; name: string | null; stale: boolean | null; ogs: any[] }>()
|
||||||
|
const ungrouped: any[] = []
|
||||||
|
for (const og of ogs) {
|
||||||
|
if (og.familyId) {
|
||||||
|
const entry = byFamily.get(og.familyId) ?? {
|
||||||
|
key: og.familyId, code: og.familyCode ?? null, name: og.familyName ?? null,
|
||||||
|
stale: og.familyStale ?? null, ogs: [] as any[],
|
||||||
|
}
|
||||||
|
entry.ogs.push(og)
|
||||||
|
byFamily.set(og.familyId, entry)
|
||||||
|
} else {
|
||||||
|
ungrouped.push(og)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodes: any[] = [...byFamily.values()].map((f) => ({
|
||||||
|
id: `fam-${f.key}`,
|
||||||
|
label: `${f.code ?? f.name ?? f.key}(${f.ogs.length})`,
|
||||||
|
isFamily: true,
|
||||||
|
familyId: f.key,
|
||||||
|
familyCode: f.code,
|
||||||
|
familyStale: f.stale,
|
||||||
|
children: mapOgs(f.ogs, `fam-${f.key}`),
|
||||||
|
}))
|
||||||
|
if (ungrouped.length) {
|
||||||
|
nodes.push({
|
||||||
|
id: `fam-none-${bucketPrefix}`,
|
||||||
|
label: `未入族(${ungrouped.length})`,
|
||||||
|
isFamily: true,
|
||||||
|
familyId: null,
|
||||||
|
familyCode: null,
|
||||||
|
familyStale: null,
|
||||||
|
children: mapOgs(ungrouped, `fam-none-${bucketPrefix}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCat(node: any): any {
|
||||||
|
const children = (node.children || []).map(mapCat)
|
||||||
|
const familyNodes = groupByFamily(node.originGoods || [], node.categoryId)
|
||||||
return {
|
return {
|
||||||
id: 'rc-' + node.categoryId,
|
id: 'rc-' + node.categoryId,
|
||||||
label: node.categoryName,
|
label: node.categoryName,
|
||||||
configuredCount: node.configuredCount ?? 0,
|
configuredCount: node.configuredCount ?? 0,
|
||||||
totalCount: node.totalCount ?? 0,
|
totalCount: node.totalCount ?? 0,
|
||||||
children: [...children, ...goods],
|
children: [...children, ...familyNodes],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rightTreeData.value = tree.tree.map(mapCat)
|
rightTreeData.value = tree.tree.map(mapCat)
|
||||||
@@ -439,18 +484,39 @@ function onConfigCascaderChange(val: any) {
|
|||||||
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 配置时把勾选的兄弟链接与主链接归入同一族(无族则自动建族);失败不阻断原配置流程 */
|
||||||
|
async function ensureFamilyMembership() {
|
||||||
|
const primaryId = String(configPrimaryId.value || configOG.value.rawId)
|
||||||
|
const checked = configChecked.value.filter((id) => id !== primaryId)
|
||||||
|
if (!checked.length) return
|
||||||
|
const primaryNode = findOgNodeById(primaryId)
|
||||||
|
try {
|
||||||
|
if (primaryNode?.familyId) {
|
||||||
|
await productFamiliesApi.updateMembers(primaryNode.familyId, {
|
||||||
|
addOriginGoodIds: [...new Set([primaryId, ...checked])],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await productFamiliesApi.create({
|
||||||
|
familyName: truncateToProcess(primaryNode?.goodName ?? configOG.value.goodName) || (configOG.value.goodName ?? ''),
|
||||||
|
originGoodIds: [primaryId, ...checked],
|
||||||
|
primaryOriginGoodId: primaryId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.warning(e?.response?.data?.message || '挂族失败,商品仍按原方式配置')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleConfigSubmit() {
|
async function handleConfigSubmit() {
|
||||||
if (!configForm.value.countryId) { ElMessage.warning('请选择国家'); return }
|
if (!configForm.value.countryId) { ElMessage.warning('请选择国家'); return }
|
||||||
if (!configForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
|
if (!configForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
|
||||||
configLoading.value = true
|
configLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
await ensureFamilyMembership()
|
||||||
await goodsApi.createGood({
|
await goodsApi.createGood({
|
||||||
goodName: configOG.value.goodName,
|
goodName: configOG.value.goodName,
|
||||||
goodImage: configForm.value.goodImage || undefined,
|
goodImage: configForm.value.goodImage || undefined,
|
||||||
originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
|
originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
|
||||||
mergedOriginGoodIds: configChecked.value
|
|
||||||
.filter((id) => id !== configPrimaryId.value)
|
|
||||||
.map(Number),
|
|
||||||
countryId: Number(configForm.value.countryId),
|
countryId: Number(configForm.value.countryId),
|
||||||
categoryId: Number(configForm.value.categoryId),
|
categoryId: Number(configForm.value.categoryId),
|
||||||
tagIds: configForm.value.tagIds.map(Number),
|
tagIds: configForm.value.tagIds.map(Number),
|
||||||
@@ -518,8 +584,6 @@ const editLoading = ref(false)
|
|||||||
const editDetailLoading = ref(false)
|
const editDetailLoading = ref(false)
|
||||||
const detailSyncing = ref(false)
|
const detailSyncing = ref(false)
|
||||||
const editGood = ref<Good | GoodDetail | null>(null)
|
const editGood = ref<Good | GoodDetail | null>(null)
|
||||||
const editMerged = ref<MergedOriginGoodSummary[]>([])
|
|
||||||
const editMergeSearch = ref('')
|
|
||||||
const originalOriginGoodId = ref<string>('')
|
const originalOriginGoodId = ref<string>('')
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||||
@@ -542,52 +606,6 @@ const editSizeRows = computed(() => {
|
|||||||
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
||||||
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
||||||
|
|
||||||
// 候选 = 右栏树全部 og,按搜索词过滤
|
|
||||||
const editMergeCandidates = computed(() => {
|
|
||||||
if (!editMergeSearch.value) return []
|
|
||||||
const kw = editMergeSearch.value
|
|
||||||
const result: any[] = []
|
|
||||||
function traverse(nodes: any[]) {
|
|
||||||
for (const n of nodes) {
|
|
||||||
if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n)
|
|
||||||
if (n.children?.length) traverse(n.children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
traverse(rightTreeData.value)
|
|
||||||
return result.filter(
|
|
||||||
(n) => String(n.rawId) !== String(editGood.value?.originGoodId)
|
|
||||||
&& !editMerged.value.some((m) => m.id === String(n.rawId)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function addEditMerge(node: any) {
|
|
||||||
editMerged.value.push({
|
|
||||||
id: String(node.rawId), sdsGoodId: node.sdsGoodId,
|
|
||||||
goodName: node.goodName, goodImage: node.goodImage,
|
|
||||||
goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail),
|
|
||||||
variantCount: node.variantCount ?? 0,
|
|
||||||
})
|
|
||||||
editMergeSearch.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function promoteMerged(m: MergedOriginGoodSummary) {
|
|
||||||
if (!editGood.value) return
|
|
||||||
const oldPrimaryId = editGood.value.originGoodId
|
|
||||||
const oldPrimary = editGood.value.originGood
|
|
||||||
editGood.value = {
|
|
||||||
...editGood.value,
|
|
||||||
originGoodId: m.id,
|
|
||||||
originGood: oldPrimary
|
|
||||||
? { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }
|
|
||||||
: { id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName } as any,
|
|
||||||
}
|
|
||||||
editMerged.value = [
|
|
||||||
{ id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null,
|
|
||||||
goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null,
|
|
||||||
hasDetail: oldPrimary?.hasDetail ?? false, variantCount: (oldPrimary as any)?.variantCount ?? 0 },
|
|
||||||
...editMerged.value.filter((x) => x.id !== m.id),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
const customContentForm = ref({
|
const customContentForm = ref({
|
||||||
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
|
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
|
||||||
minWeightG: '', productionProcess: '', materialDescription: '',
|
minWeightG: '', productionProcess: '', materialDescription: '',
|
||||||
@@ -657,9 +675,38 @@ function addCustomVariant() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Edit family (产品族) ───
|
||||||
|
const editFamilyId = ref<string>('')
|
||||||
|
const editFamilyTouched = ref(false)
|
||||||
|
const editFamilyOptions = ref<Array<{ id: string; familyCode: string | null; familyName: string }>>([])
|
||||||
|
const editFamilyLoading = ref(false)
|
||||||
|
|
||||||
|
function initEditFamily(family: { familyId?: string; familyCode?: string | null; familyName?: string } | null) {
|
||||||
|
editFamilyId.value = family?.familyId ?? ''
|
||||||
|
editFamilyTouched.value = false
|
||||||
|
editFamilyOptions.value = family?.familyId
|
||||||
|
? [{ id: family.familyId, familyCode: family.familyCode ?? null, familyName: family.familyName ?? '' }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchEditFamilies(q: string) {
|
||||||
|
if (!q) return
|
||||||
|
editFamilyLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await productFamiliesApi.list({ keyword: q, page: 1, pageSize: 30 })
|
||||||
|
editFamilyOptions.value = res.items.map((f) => ({ id: f.id, familyCode: f.familyCode, familyName: f.familyName }))
|
||||||
|
} finally {
|
||||||
|
editFamilyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditFamilyChange(v: string | null | undefined) {
|
||||||
|
editFamilyTouched.value = true
|
||||||
|
editFamilyId.value = v ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
async function openEdit(g: Good) {
|
async function openEdit(g: Good) {
|
||||||
editGood.value = g
|
editGood.value = g
|
||||||
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
|
||||||
originalOriginGoodId.value = g.originGoodId
|
originalOriginGoodId.value = g.originGoodId
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: g.id, goodName: g.goodName,
|
id: g.id, goodName: g.goodName,
|
||||||
@@ -670,12 +717,13 @@ async function openEdit(g: Good) {
|
|||||||
tagIds: (g.tags || []).map(t => t.id),
|
tagIds: (g.tags || []).map(t => t.id),
|
||||||
positionId: g.positionId || '',
|
positionId: g.positionId || '',
|
||||||
}
|
}
|
||||||
|
initEditFamily((g as any).originGood?.family ?? null)
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
editDetailLoading.value = true
|
editDetailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const detail = await goodsApi.getGoodById(g.id)
|
const detail = await goodsApi.getGoodById(g.id)
|
||||||
editGood.value = detail
|
editGood.value = detail
|
||||||
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
initEditFamily((detail.originGood as any)?.family ?? null)
|
||||||
if (detail.originGood?.isCustom) fillCustomContent(detail)
|
if (detail.originGood?.isCustom) fillCustomContent(detail)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
||||||
@@ -709,7 +757,6 @@ async function handleSyncOneDetail() {
|
|||||||
const result = await syncApi.syncOneProductDetail(goodId)
|
const result = await syncApi.syncOneProductDetail(goodId)
|
||||||
const detail = await goodsApi.getGoodById(editGood.value!.id)
|
const detail = await goodsApi.getGoodById(editGood.value!.id)
|
||||||
editGood.value = detail
|
editGood.value = detail
|
||||||
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
|
||||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||||
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -795,11 +842,14 @@ async function handleEditSubmit() {
|
|||||||
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value
|
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value
|
||||||
? Number(editGood.value!.originGoodId)
|
? Number(editGood.value!.originGoodId)
|
||||||
: undefined,
|
: undefined,
|
||||||
mergedOriginGoodIds: editIsCustom.value ? undefined : editMerged.value.map((m) => Number(m.id)),
|
|
||||||
countryId: Number(editForm.value.countryId),
|
countryId: Number(editForm.value.countryId),
|
||||||
categoryId: Number(editForm.value.categoryId),
|
categoryId: Number(editForm.value.categoryId),
|
||||||
tagIds: editForm.value.tagIds.map(Number),
|
tagIds: editForm.value.tagIds.map(Number),
|
||||||
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
||||||
|
// 族变更:仅在用户改动过选择时提交(undefined = 不动,null = 脱离族)
|
||||||
|
...(editFamilyTouched.value
|
||||||
|
? { familyId: editFamilyId.value ? Number(editFamilyId.value) : null }
|
||||||
|
: {}),
|
||||||
} as any)
|
} as any)
|
||||||
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
|
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
@@ -1739,8 +1789,12 @@ onMounted(() => loadAll())
|
|||||||
@click.stop="openConfigFromRightTree(data)"
|
@click.stop="openConfigFromRightTree(data)"
|
||||||
>配置</el-button>
|
>配置</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="og-cat-node">
|
<div v-else class="og-cat-node" :class="{ 'og-family-node': data.isFamily }">
|
||||||
<span>{{ data.label }}</span>
|
<span class="og-cat-label">{{ data.isFamily ? '族' : '' }} {{ data.label }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.isFamily && data.familyStale"
|
||||||
|
size="small" type="warning" class="og-family-stale"
|
||||||
|
>待处理</el-tag>
|
||||||
<span v-if="data.totalCount" class="og-cat-count">
|
<span v-if="data.totalCount" class="og-cat-count">
|
||||||
<template v-if="data.configuredCount < data.totalCount">
|
<template v-if="data.configuredCount < data.totalCount">
|
||||||
{{ data.configuredCount }}/{{ data.totalCount }}
|
{{ data.configuredCount }}/{{ data.totalCount }}
|
||||||
@@ -1851,12 +1905,12 @@ onMounted(() => loadAll())
|
|||||||
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
|
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
|
||||||
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="configSiblings.length" label="合并同名">
|
<el-form-item v-if="configSiblings.length" label="同族链接">
|
||||||
<div class="config-merge-box">
|
<div class="config-merge-box">
|
||||||
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。</div>
|
<div class="config-merge-tip">勾选的同分类链接将与主链接一起归入同一产品族(并集尺码/包装 + 五维价格矩阵);主链接决定详情与跳转。</div>
|
||||||
<div class="config-merge-primary">
|
<div class="config-merge-primary">
|
||||||
<el-radio-group v-model="configPrimaryId">
|
<el-radio-group v-model="configPrimaryId">
|
||||||
<el-radio :value="String(configOG.rawId)">主源:{{ configOG.goodName }}</el-radio>
|
<el-radio :value="String(configOG.rawId)">主链接:{{ configOG.goodName }}</el-radio>
|
||||||
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
|
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
@@ -1954,32 +2008,34 @@ onMounted(() => loadAll())
|
|||||||
>同步详情</el-button>
|
>同步详情</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!editIsCustom" class="edit-merged-box">
|
<div v-if="!editIsCustom" class="edit-merged-box">
|
||||||
<div class="edit-merged-title">
|
<div class="edit-merged-title">产品族合并(新机制)</div>
|
||||||
关联原产品(主源 + 副源)
|
<div class="config-merge-tip" style="padding: 4px 0 8px">
|
||||||
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
|
多链接合并已由「产品族」承载:同族链接自动合并尺码/包装并集与五维价格矩阵(下方"所属族"可切换)。
|
||||||
<el-icon><QuestionFilled /></el-icon>
|
旧的副源关联已废弃,仅历史数据只读保留。
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-merged-list">
|
|
||||||
<div class="edit-merged-item primary">
|
|
||||||
<span class="edit-merged-tag">主</span>
|
|
||||||
<span>{{ editGood?.originGood?.goodName }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
|
|
||||||
<span class="edit-merged-tag sub">副</span>
|
|
||||||
<span>{{ m.goodName }}</span>
|
|
||||||
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
|
|
||||||
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
|
|
||||||
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
|
|
||||||
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
|
|
||||||
@click="addEditMerge(c)" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
</div>
|
||||||
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
|
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
|
||||||
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
||||||
|
<el-form-item label="所属族">
|
||||||
|
<el-select
|
||||||
|
:model-value="editFamilyId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
placeholder="搜索族名称/编码切换,清空则脱离族"
|
||||||
|
:remote-method="searchEditFamilies"
|
||||||
|
:loading="editFamilyLoading"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="onEditFamilyChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="f in editFamilyOptions"
|
||||||
|
:key="f.id"
|
||||||
|
:value="f.id"
|
||||||
|
:label="`${f.familyCode ? f.familyCode + ' · ' : ''}${f.familyName}`"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -2392,6 +2448,8 @@ onMounted(() => loadAll())
|
|||||||
}
|
}
|
||||||
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||||
.og-family-tag { flex-shrink: 0; }
|
.og-family-tag { flex-shrink: 0; }
|
||||||
|
.og-family-node .og-cat-label { color: var(--el-color-primary); font-weight: 600; }
|
||||||
|
.og-family-stale { margin-left: 4px; }
|
||||||
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
|
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
|
||||||
.og-badge {
|
.og-badge {
|
||||||
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px;
|
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px;
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ THROTTLE_LIMIT=120
|
|||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
|
||||||
# Gray release: expose product-family block (union size chart + 5-dim price matrix)
|
# Product-family public read path (family block + family variant union in
|
||||||
# in GET /public/goods/:goodId responses. Off = response shape identical to before.
|
# GET /public/goods/:goodId). Set to 'false' for emergency rollback to legacy behavior.
|
||||||
PUBLIC_DETAIL_FROM_FAMILY=false
|
PUBLIC_DETAIL_FROM_FAMILY=true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface GoodRelations {
|
|||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: unknown;
|
goodPrice: unknown;
|
||||||
|
family?: { id: bigint; familyCode: string | null; familyName: string } | null;
|
||||||
detail?: {
|
detail?: {
|
||||||
productCode: string | null;
|
productCode: string | null;
|
||||||
syncedAt: Date;
|
syncedAt: Date;
|
||||||
@@ -127,6 +128,7 @@ export class GoodDto {
|
|||||||
sizeRowCount: number;
|
sizeRowCount: number;
|
||||||
packageRowCount: number;
|
packageRowCount: number;
|
||||||
productCode: string | null;
|
productCode: string | null;
|
||||||
|
family?: { familyId: string; familyCode: string | null; familyName: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
static from(
|
static from(
|
||||||
@@ -215,6 +217,13 @@ export class GoodDto {
|
|||||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||||
productCode: rel.originGood.detail?.productCode ?? null,
|
productCode: rel.originGood.detail?.productCode ?? null,
|
||||||
|
family: rel.originGood.family
|
||||||
|
? {
|
||||||
|
familyId: rel.originGood.family.id.toString(),
|
||||||
|
familyCode: rel.originGood.family.familyCode,
|
||||||
|
familyName: rel.originGood.family.familyName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,4 +61,15 @@ export class UpdateGoodDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
goodImage?: string | null;
|
goodImage?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
nullable: true,
|
||||||
|
type: Number,
|
||||||
|
description: '所属产品族 id(null = 脱离族)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
familyId?: number | null;
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ const GOOD_INCLUDE = {
|
|||||||
detail: true,
|
detail: true,
|
||||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
_count: { select: { variants: true } },
|
_count: { select: { variants: true } },
|
||||||
|
family: { select: { id: true, familyCode: true, familyName: true, stale: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: true } },
|
goodTags: { include: { tag: true } },
|
||||||
@@ -334,6 +335,18 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||||
|
if (dto.familyId !== undefined) {
|
||||||
|
if (dto.familyId !== null) {
|
||||||
|
const family = await this.prisma.productFamily.findUnique({
|
||||||
|
where: { id: BigInt(dto.familyId) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
|
||||||
|
data.family = { connect: { id: family.id } };
|
||||||
|
} else {
|
||||||
|
data.family = { disconnect: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dto.tagIds !== undefined) {
|
if (dto.tagIds !== undefined) {
|
||||||
for (const tagId of dto.tagIds) {
|
for (const tagId of dto.tagIds) {
|
||||||
await this.ensureTag(tagId);
|
await this.ensureTag(tagId);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe('ProductFamiliesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
|
it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
|
||||||
|
const code = `DG${stamp}T`;
|
||||||
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
|
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
|
||||||
craftLabel: '单面印花',
|
craftLabel: '单面印花',
|
||||||
logisticsLabel: '包邮',
|
logisticsLabel: '包邮',
|
||||||
@@ -58,18 +59,18 @@ describe('ProductFamiliesService', () => {
|
|||||||
});
|
});
|
||||||
const f1 = (await service.create({
|
const f1 = (await service.create({
|
||||||
familyName: '测试T恤',
|
familyName: '测试T恤',
|
||||||
familyCode: 'DGTEST',
|
familyCode: code,
|
||||||
originGoodIds: [a.id.toString()],
|
originGoodIds: [a.id.toString()],
|
||||||
primaryOriginGoodId: a.id.toString(),
|
primaryOriginGoodId: a.id.toString(),
|
||||||
})) as any;
|
})) as any;
|
||||||
createdFamilyIds.push(BigInt(f1.id));
|
createdFamilyIds.push(BigInt(f1.id));
|
||||||
expect(f1.familyCode).toBe('DGTEST');
|
expect(f1.familyCode).toBe(code);
|
||||||
expect(f1._count.originGoods).toBe(1);
|
expect(f1._count.originGoods).toBe(1);
|
||||||
expect((f1.priceMatrix as any).rows).toHaveLength(1);
|
expect((f1.priceMatrix as any).rows).toHaveLength(1);
|
||||||
|
|
||||||
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: 'DGTEST' })) as any;
|
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: code })) as any;
|
||||||
createdFamilyIds.push(BigInt(f2.id));
|
createdFamilyIds.push(BigInt(f2.id));
|
||||||
expect(f2.familyCode).toBe('DGTEST-2');
|
expect(f2.familyCode).toBe(`${code}-2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detail:不存在 404', async () => {
|
it('detail:不存在 404', async () => {
|
||||||
@@ -77,11 +78,12 @@ describe('ProductFamiliesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => {
|
it('list:keyword 过滤 familyName/familyCode + 分页字段', async () => {
|
||||||
|
const code = `DG${stamp}T`;
|
||||||
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
|
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
|
||||||
expect(res.total).toBeGreaterThanOrEqual(2);
|
expect(res.total).toBeGreaterThanOrEqual(2);
|
||||||
expect(res.items.length).toBeGreaterThanOrEqual(2);
|
expect(res.items.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(res.page).toBe(1);
|
expect(res.page).toBe(1);
|
||||||
const byCode = (await service.list({ keyword: 'DGTEST-2' })) as any;
|
const byCode = (await service.list({ keyword: `${code}-2` })) as any;
|
||||||
expect(byCode.total).toBe(1);
|
expect(byCode.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,26 +92,59 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('开关关闭:响应完全不含 family 键(与现状形状一致)', async () => {
|
it('默认(未设开关):输出族块', async () => {
|
||||||
|
delete process.env.PUBLIC_DETAIL_FROM_FAMILY
|
||||||
|
const detail = await service.getGood(sdsGoodId);
|
||||||
|
expect(detail.goodId).toBe(sdsGoodId);
|
||||||
|
expect(detail.family).toBeTruthy();
|
||||||
|
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
|
||||||
|
expect(detail.family!.minPrice).toBe('25');
|
||||||
|
// 旧字段保留(向后兼容)
|
||||||
|
expect(detail.variants.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => {
|
||||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
|
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
|
||||||
const detail = await service.getGood(sdsGoodId);
|
const detail = await service.getGood(sdsGoodId);
|
||||||
expect(detail.goodId).toBe(sdsGoodId);
|
expect(detail.goodId).toBe(sdsGoodId);
|
||||||
expect('family' in detail).toBe(false);
|
expect('family' in detail).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('开关开启:输出族块(物化矩阵 + 并集 + 起价)', async () => {
|
it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => {
|
||||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||||
const detail = await service.getGood(sdsGoodId);
|
// 再加一个同族成员(不同仓库段)
|
||||||
expect(detail.family).not.toBeNull();
|
const og2 = await prisma.originGood.create({
|
||||||
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
|
data: {
|
||||||
expect(detail.family!.familyId).toBe(familyId.toString());
|
sdsGoodId: `pubfam-m-${stamp}`,
|
||||||
expect(detail.family!.crafts).toEqual(['单面印花']);
|
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花-二仓`,
|
||||||
expect(detail.family!.logistics).toEqual(['包邮']);
|
craftLabel: '单面印花',
|
||||||
expect(detail.family!.priceMatrix).toBeTruthy();
|
logisticsLabel: '包邮',
|
||||||
expect(detail.family!.minPrice).toBe('25');
|
familyId,
|
||||||
// 旧字段保留(向后兼容)
|
},
|
||||||
expect(detail.variants.length).toBe(1);
|
});
|
||||||
expect(detail.sizeChart).toBeDefined();
|
createdOriginGoodIds.push(og2.id);
|
||||||
|
await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og2.id,
|
||||||
|
sdsVariantId: 'pf-m-v1',
|
||||||
|
sku: `PF-${stamp}-M`,
|
||||||
|
sizeId: 'size_M',
|
||||||
|
sizeName: 'M',
|
||||||
|
colorId: 'color_blk',
|
||||||
|
colorName: '黑色',
|
||||||
|
price: new Prisma.Decimal(26),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接)
|
||||||
|
const detail = await service.getGood(`pubfam-m-${stamp}`);
|
||||||
|
expect(detail.goodId).toBe(sdsGoodId);
|
||||||
|
const skus = detail.variants.map((v) => v.sku);
|
||||||
|
expect(skus).toContain(`PF-${stamp}-S`);
|
||||||
|
expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集
|
||||||
|
// 清理:把成员移出族避免影响其他用例
|
||||||
|
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } });
|
||||||
|
await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('无族商品:开关开启也不含 family 键', async () => {
|
it('无族商品:开关开启也不含 family 键', async () => {
|
||||||
|
|||||||
@@ -221,7 +221,9 @@ export class PublicService {
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
||||||
// Merged secondary sources also resolve to the same good.
|
// 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义)
|
||||||
|
{ family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } },
|
||||||
|
// 历史副源关联(good_origin_goods)只读保留,仍可命中
|
||||||
{
|
{
|
||||||
mergedOriginGoods: {
|
mergedOriginGoods: {
|
||||||
some: { originGood: { sdsGoodId: goodId, delisted: false } },
|
some: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||||
@@ -235,7 +237,25 @@ export class PublicService {
|
|||||||
if (!good) {
|
if (!good) {
|
||||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||||
}
|
}
|
||||||
const dto = this.toPublicGoodDetail(good);
|
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
|
||||||
|
let familyVariants: Array<{
|
||||||
|
originGoodId: bigint;
|
||||||
|
variant: PublicGoodRow['originGood']['variants'][number];
|
||||||
|
}> = [];
|
||||||
|
const familyId = good.originGood.family?.id;
|
||||||
|
if (familyId) {
|
||||||
|
const members = await this.prisma.originGood.findMany({
|
||||||
|
where: { familyId, delisted: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
familyVariants = members
|
||||||
|
.filter((m) => m.id !== good.originGoodId)
|
||||||
|
.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })));
|
||||||
|
}
|
||||||
|
const dto = this.toPublicGoodDetail(good, familyVariants);
|
||||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -362,13 +382,35 @@ export class PublicService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
private toPublicGoodDetail(
|
||||||
|
good: PublicGoodRow,
|
||||||
|
familyVariants: Array<{
|
||||||
|
originGoodId: bigint;
|
||||||
|
variant: PublicGoodRow['originGood']['variants'][number];
|
||||||
|
}> = [],
|
||||||
|
): PublicGoodDetailDto {
|
||||||
const base = this.toPublicGood(good);
|
const base = this.toPublicGood(good);
|
||||||
const detail = good.originGood.detail;
|
const detail = good.originGood.detail;
|
||||||
// Merge primary and secondary origin good variants (dedup identical URLs).
|
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
|
||||||
|
// 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重。
|
||||||
|
const seen = new Set<string>(['']);
|
||||||
|
const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
|
||||||
|
const key = `${originGoodId}:${variant.sdsVariantId}`;
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
return variant;
|
||||||
|
};
|
||||||
|
good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
|
||||||
const allVariants = [
|
const allVariants = [
|
||||||
...good.originGood.variants,
|
...good.originGood.variants,
|
||||||
...good.mergedOriginGoods.flatMap((m) => m.originGood.variants),
|
...familyVariants
|
||||||
|
.map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
|
||||||
|
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
||||||
|
...good.mergedOriginGoods.flatMap((m) =>
|
||||||
|
m.originGood.variants
|
||||||
|
.map((variant) => dedupe(m.originGoodId, variant))
|
||||||
|
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -417,13 +459,13 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true
|
* 族块(设计 D4:零新增公开端点):默认输出;仅当 PUBLIC_DETAIL_FROM_FAMILY
|
||||||
* 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。
|
* 显式设为 'false' 时关闭(应急回退开关)。数据全部来自 ProductFamily 的物化 JSON。
|
||||||
*/
|
*/
|
||||||
private familyBlock(
|
private familyBlock(
|
||||||
good: PublicGoodRow,
|
good: PublicGoodRow,
|
||||||
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
|
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
|
||||||
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') return {};
|
if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {};
|
||||||
const family = good.originGood.family;
|
const family = good.originGood.family;
|
||||||
if (!family || !family.priceMatrix) return {};
|
if (!family || !family.priceMatrix) return {};
|
||||||
const matrix = family.priceMatrix as {
|
const matrix = family.priceMatrix as {
|
||||||
|
|||||||
@@ -39,16 +39,15 @@
|
|||||||
|
|
||||||
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
|
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
|
||||||
|
|
||||||
## 多源合并商品
|
## 多源合并商品(已废弃,由产品族替代)
|
||||||
|
|
||||||
后台支持把名称相同但工厂/仓库不同的多个 SDS 原产品合并为一个官网商品:
|
> **2026-08-28 起,该机制已被「产品族」完全替代**:多链接合并由族承载(尺码/包装并集 +
|
||||||
|
> 五维价格矩阵),配置/编辑商品不再写入 `mergedOriginGoodIds`;`good_origin_goods` 表
|
||||||
|
> 只读保留(历史数据仍可命中详情),观察期后删除。以下为历史行为记录:
|
||||||
|
|
||||||
- 数据层:主源存 `goods.origin_good_id`,副源存中间表 `good_origin_goods`。
|
- 数据层:主源存 `goods.origin_good_id`,副源存中间表 `good_origin_goods`。
|
||||||
- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情,即副源的旧链接不会 404。
|
- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情(族机制下
|
||||||
- 变体合并:详情中的 SKU 是「主源变体 ∪ 全部副源变体」,按颜色归组展示媒体图;价格与详情页内容以主源为准。
|
同样成立:族内任何成员链接的 sdsGoodId 均命中同一商品)。
|
||||||
- 后台维护入口:
|
|
||||||
- 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`);
|
|
||||||
- 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。
|
|
||||||
|
|
||||||
> 该 Good 级合并能力保留可用;新一级的「产品族(SPU)合并」见下节,公开读路径接入族数据属于三期范围。
|
> 该 Good 级合并能力保留可用;新一级的「产品族(SPU)合并」见下节,公开读路径接入族数据属于三期范围。
|
||||||
|
|
||||||
@@ -101,8 +100,12 @@ pnpm --filter @inkreach/api backfill:product-families
|
|||||||
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
|
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
|
||||||
```
|
```
|
||||||
|
|
||||||
**公开读路径灰度(三期已上线,默认关闭)**:环境变量 `PUBLIC_DETAIL_FROM_FAMILY=true` 时,
|
**公开读路径(三期,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
||||||
`GET /public/goods/:goodId` 在既有响应上**增量**输出 `family` 块:
|
`GET /public/goods/:goodId` 行为:
|
||||||
|
|
||||||
|
- **变体并集(替代旧副源机制)**:`variants` = 主链接 ∪ 全体族成员 ∪ 旧副源(过渡期),
|
||||||
|
按 `(链接, 变体)` 去重;`mediaByColor` 同源。任何族成员链接的 sdsGoodId 均命中同一商品。
|
||||||
|
- **族块**(默认输出):`family` 含并集尺码表/包装 + 五维价格矩阵 + 族起价:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ apps/api/
|
|||||||
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
||||||
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
||||||
| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 |
|
| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 |
|
||||||
| `/public/goods/:id` `GET` | 商品详情;`PUBLIC_DETAIL_FROM_FAMILY=true` 且主链接有族时额外输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),开关关闭时响应形状与现状一致 | 公开 |
|
| `/public/goods/:id` `GET` | 商品详情;默认输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),`variants` = 主链接 ∪ 族成员 ∪ 旧副源(去重);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退旧行为 | 公开 |
|
||||||
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
||||||
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
|
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
|
||||||
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
|
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 产品族 × 商品配置页完整融入 实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** 「商品配置」页深度接入族:右树按族分组、配置勾选兄弟=挂族(双写兼容)、编辑表单可查看/切换族。
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
1. **后端**:`UpdateGoodDto.familyId?: number | null`;`update()` 校验族存在后写入;`GOOD_INCLUDE.originGood` 加 `family` select 并透出到 `GoodDetailDto`(`family: { familyId, familyCode, familyName } | null`)。测试:update 改族/清族/非法族 404。
|
||||||
|
2. **右树按族分组**:`buildRightTree.mapCat` 内按 `og.familyId` 分桶:族节点(`isFamily: true`,label=`编码 · 族名 (n)`)+ 未入族桶;og 节点 `parentId` 指向族节点(`collectSiblings` 语义随之升级为同族);非 OG 节点模板分支渲染族节点。过滤/展开/计数逻辑不改(遍历式,天然兼容)。
|
||||||
|
3. **配置弹窗挂族**:`handleConfigSubmit` 在 createGood 前:主链接有族 → `updateMembers(add: 勾选兄弟)`;无族且有勾选 → `POST /product-families`(族名=主链接名 3 段截断, `originGoodIds=[主+勾选]`);**保留 mergedOriginGoodIds 双写**(公开读路径兼容观察期)。
|
||||||
|
4. **编辑表单族显示/切换**:编辑弹窗显示 `所属族`(familyCode/familyName),可搜索切换(远程搜索族列表),保存时 `updateGood({ familyId })`。
|
||||||
|
5. 验证:api 全量 + admin vitest + vue-tsc/build + 冒烟;文档更新(structs/product-center);合并 develop。
|
||||||
Reference in New Issue
Block a user