merge: feature/link-tags-and-sku-dims-dev into develop (local only, not pushed)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import request from './request'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, OriginGoodTagsResult, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
getTree: () => {
|
||||
@@ -9,4 +9,18 @@ export const originGoodsApi = {
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||
},
|
||||
|
||||
getTags: (id: string) => {
|
||||
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
|
||||
},
|
||||
|
||||
// 人工接管链接标签(全量替换,自动同步不再覆盖)
|
||||
updateTags: (id: string, tagIds: string[]) => {
|
||||
return request.put<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`, { tagIds: tagIds.map(Number) })
|
||||
},
|
||||
|
||||
// 恢复自动派生(清掉人工标签)
|
||||
resetTags: (id: string) => {
|
||||
return request.delete<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -402,17 +402,35 @@ export interface OriginGoodsTreeResponse {
|
||||
}
|
||||
|
||||
// Product Family (SPU layer) types
|
||||
export interface OriginGoodTagInfo {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor: string | null
|
||||
tagFontColor: string | null
|
||||
manual: boolean
|
||||
}
|
||||
|
||||
/** 链接标签配置结果(origin-goods/:id/tags) */
|
||||
export interface OriginGoodTagsResult {
|
||||
tagsManual: boolean
|
||||
tags: OriginGoodTagInfo[]
|
||||
}
|
||||
|
||||
export interface ProductFamilyMember {
|
||||
id: string
|
||||
sdsGoodId: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
source: 'SDS' | 'CUSTOM'
|
||||
delisted: boolean
|
||||
skuCode: string | null
|
||||
logisticsLabel: string | null
|
||||
craftLabel: string | null
|
||||
warehouseLabel: string | null
|
||||
tagsManual: boolean
|
||||
originGoodTags: Array<{ id: string; manual: boolean; tag: OriginGoodTagInfo }>
|
||||
variantCount: number
|
||||
}
|
||||
|
||||
export interface FamilyPriceOverrideRow {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cleanLinkName,
|
||||
deriveLinkTagNames,
|
||||
linkDims,
|
||||
parseLinkName,
|
||||
sameOriginGroup,
|
||||
truncateToProcess,
|
||||
@@ -106,3 +107,23 @@ describe('deriveLinkTagNames', () => {
|
||||
expect(deriveLinkTagNames(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkDims', () => {
|
||||
it('解析物流备注/工艺/印花数量', () => {
|
||||
expect(linkDims('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
|
||||
logistics: '包邮',
|
||||
crafts: ['烫画'],
|
||||
printCount: '单面印花',
|
||||
});
|
||||
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
|
||||
logistics: '不包邮光板',
|
||||
crafts: ['不打印', '光板'],
|
||||
printCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('无括号结构时物流为 null,手工名称工艺默认烫画', () => {
|
||||
expect(linkDims('纯棉T恤')).toEqual({ logistics: null, crafts: ['烫画'], printCount: null });
|
||||
expect(linkDims(null)).toEqual({ logistics: null, crafts: [], printCount: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,35 @@ export function cleanLinkName(name: string | null | undefined): string {
|
||||
|
||||
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||
|
||||
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
|
||||
export interface LinkDims {
|
||||
logistics: string | null;
|
||||
crafts: string[];
|
||||
printCount: string | null;
|
||||
}
|
||||
|
||||
/** 从链接名称解析定价维度;工艺无命中时默认烫画(与派生标签规则一致) */
|
||||
export function linkDims(name: string | null | undefined): LinkDims {
|
||||
if (!name) return { logistics: null, crafts: [], printCount: null };
|
||||
const head = name.split('-')[0] ?? '';
|
||||
const openCandidates = [head.indexOf('('), head.indexOf('(')].filter((i) => i >= 0);
|
||||
const openIdx = openCandidates.length ? Math.min(...openCandidates) : -1;
|
||||
const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'));
|
||||
const logistics =
|
||||
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
|
||||
const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k));
|
||||
const printCount = name.includes('双面印花')
|
||||
? '双面印花'
|
||||
: name.includes('单面印花')
|
||||
? '单面印花'
|
||||
: null;
|
||||
return {
|
||||
logistics,
|
||||
crafts: craftHits.length > 0 ? craftHits : ['烫画'],
|
||||
printCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
|
||||
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups'
|
||||
import { originGoodsApi } from '@/api/origin-goods'
|
||||
import { syncApi } from '@/api/sync'
|
||||
import { sameFamily } from '@/utils/family-match'
|
||||
import { cleanLinkName, deriveLinkTagNames, truncateToProcess } from '@/utils/origin-name'
|
||||
import { cleanLinkName, deriveLinkTagNames, linkDims, truncateToProcess } from '@/utils/origin-name'
|
||||
import { productFamiliesApi } from '@/api/product-families'
|
||||
|
||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||
@@ -566,7 +566,7 @@ const editGood = ref<Good | GoodDetail | null>(null)
|
||||
const originalOriginGoodId = ref<string>('')
|
||||
const editForm = ref({
|
||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||
categoryId: '', tagIds: [] as string[], positionId: '',
|
||||
categoryId: '', positionId: '',
|
||||
})
|
||||
|
||||
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
|
||||
@@ -654,14 +654,57 @@ function addCustomVariant() {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Edit family(编辑弹窗的族成员管理,替代旧主源/副源) ───
|
||||
// ─── Edit family(编辑弹窗的「关联原产品」:成员可展开查看详情并配置标签) ───
|
||||
interface FamilyMemberRow {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
source: string
|
||||
delisted: boolean
|
||||
sdsGoodId: string
|
||||
goodPrice: string | null
|
||||
variantCount: number
|
||||
logisticsLabel: string | null
|
||||
craftLabel: string | null
|
||||
warehouseLabel: string | null
|
||||
tagsManual: boolean
|
||||
tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }>
|
||||
}
|
||||
const editFamilyId = ref<string>('')
|
||||
const editFamilyCode = ref<string | null>(null)
|
||||
const editFamilyMembers = ref<Array<{ id: string; goodName: string; goodImage: string | null }>>([])
|
||||
const editFamilyMembers = ref<FamilyMemberRow[]>([])
|
||||
const familyMemberCount = ref<number | null>(null)
|
||||
const editFamilyLoaded = ref(false)
|
||||
const editFamilyAddKw = ref('')
|
||||
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
|
||||
/** 展开的成员行 key(og id) */
|
||||
const expandedMemberKeys = ref<Set<string>>(new Set())
|
||||
/** 成员标签编辑态(key = og id) */
|
||||
const memberTagEdits = ref<Record<string, string[]>>({})
|
||||
const memberTagSaving = ref<string>('')
|
||||
/** 商品自身的链接定价维度(SKU 表列) */
|
||||
const editLinkDims = computed(() => linkDims(editGood.value?.originGood?.goodName ?? null))
|
||||
|
||||
function toggleMemberExpand(key: string) {
|
||||
const next = new Set(expandedMemberKeys.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedMemberKeys.value = next
|
||||
}
|
||||
|
||||
/** 成员行视图模型:主链接行补充商品详情接口里的实时数据 */
|
||||
const familyRows = computed(() => {
|
||||
const primaryOgId = String(editGood.value?.originGoodId ?? '')
|
||||
return editFamilyMembers.value.map((m) => {
|
||||
const isPrimary = m.id === primaryOgId
|
||||
return {
|
||||
key: m.id,
|
||||
role: (isPrimary ? 'primary' : 'member') as 'primary' | 'member',
|
||||
...m,
|
||||
variantCount: isPrimary ? (editGood.value?.originGood?.variantCount ?? m.variantCount) : m.variantCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
|
||||
editFamilyId.value = family?.familyId ?? ''
|
||||
@@ -671,24 +714,72 @@ function initEditFamily(family: { familyId?: string; familyCode?: string | null
|
||||
editFamilyLoaded.value = false
|
||||
editFamilyAddKw.value = ''
|
||||
editFamilyCandidates.value = []
|
||||
expandedMemberKeys.value = new Set()
|
||||
memberTagEdits.value = {}
|
||||
if (editFamilyId.value) loadEditFamily()
|
||||
}
|
||||
|
||||
async function loadEditFamily() {
|
||||
if (!editFamilyId.value) return
|
||||
try {
|
||||
const f = await productFamiliesApi.detail(editFamilyId.value)
|
||||
const f = await productFamiliesApi.detail(editFamilyId.value) as any
|
||||
editFamilyCode.value = f.familyCode
|
||||
familyMemberCount.value = f._count.originGoods
|
||||
const primaryId = f.primaryOriginGoodId ?? editGood.value?.originGoodId
|
||||
editFamilyMembers.value = f.originGoods
|
||||
.filter((m) => String(m.id) !== String(primaryId))
|
||||
.map((m) => ({ id: String(m.id), goodName: m.goodName, goodImage: m.goodImage }))
|
||||
editFamilyMembers.value = (f.originGoods ?? []).map((m: any) => ({
|
||||
id: String(m.id),
|
||||
goodName: m.goodName ?? '',
|
||||
goodImage: m.goodImage ?? null,
|
||||
source: m.source,
|
||||
delisted: Boolean(m.delisted),
|
||||
sdsGoodId: m.sdsGoodId ?? '',
|
||||
goodPrice: m.goodPrice ?? null,
|
||||
variantCount: m._count?.variants ?? 0,
|
||||
logisticsLabel: m.logisticsLabel ?? null,
|
||||
craftLabel: m.craftLabel ?? null,
|
||||
warehouseLabel: m.warehouseLabel ?? null,
|
||||
tagsManual: Boolean(m.tagsManual),
|
||||
tags: (m.originGoodTags ?? []).map((t: any) => ({
|
||||
id: String(t.tag.id),
|
||||
tagName: t.tag.tagName,
|
||||
tagColor: t.tag.tagColor ?? null,
|
||||
manual: Boolean(t.manual),
|
||||
})),
|
||||
}))
|
||||
// 标签编辑态 = 各成员已保存标签
|
||||
const edits: Record<string, string[]> = {}
|
||||
for (const m of editFamilyMembers.value) edits[m.id] = m.tags.map((t) => t.id)
|
||||
memberTagEdits.value = edits
|
||||
} finally {
|
||||
editFamilyLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMemberTags(row: { id: string }) {
|
||||
memberTagSaving.value = row.id
|
||||
try {
|
||||
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
|
||||
ElMessage.success('标签已保存,自动同步不再覆盖该链接')
|
||||
await refreshAfterFamilyChange()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
memberTagSaving.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function resetMemberTags(row: { id: string }) {
|
||||
memberTagSaving.value = row.id
|
||||
try {
|
||||
await originGoodsApi.resetTags(row.id)
|
||||
ElMessage.success('已恢复按链接名称自动解析')
|
||||
await refreshAfterFamilyChange()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '恢复失败')
|
||||
} finally {
|
||||
memberTagSaving.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterFamilyChange() {
|
||||
if (!editGood.value) return
|
||||
const detail = await goodsApi.getGoodById(editGood.value.id)
|
||||
@@ -750,11 +841,6 @@ async function openEdit(g: Good) {
|
||||
countryId: g.countryId,
|
||||
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
|
||||
categoryId: g.categoryId,
|
||||
// 有族时:自动组(物流/工艺/位置)标签为派生数据,不进入可编辑选择
|
||||
tagIds: (g.tags || []).filter((t: any) => {
|
||||
const og = (g as any).originGood?.family
|
||||
return !og || !t.tagGroupId || !autoTagGroupIds.value.has(t.tagGroupId)
|
||||
}).map((t: any) => t.id),
|
||||
positionId: g.positionId || '',
|
||||
}
|
||||
initEditFamily((g as any).originGood?.family ?? null)
|
||||
@@ -884,7 +970,6 @@ async function handleEditSubmit() {
|
||||
: undefined,
|
||||
countryId: Number(editForm.value.countryId),
|
||||
categoryId: Number(editForm.value.categoryId),
|
||||
tagIds: editForm.value.tagIds.map(Number),
|
||||
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
||||
} as any)
|
||||
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
|
||||
@@ -1326,15 +1411,7 @@ const groupedTagOptions = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
// ─── 派生标签:物流/工艺/印花数量组由系统按链接名称自动生成,表单中只读 ───
|
||||
const isAutoTagGroup = (name: string) => /物流|工艺|位置|印花数量/.test(name)
|
||||
const autoTagGroupIds = computed(() =>
|
||||
new Set(allTagGroups.value.filter((g) => isAutoTagGroup(g.groupName)).map((g) => g.id)),
|
||||
)
|
||||
/** 当前商品的自动组标签(只读展示,来自其链接名称的解析结果) */
|
||||
const editAutoTags = computed(() =>
|
||||
((editGood.value as any)?.tags ?? []).filter((t: any) => autoTagGroupIds.value.has(t.tagGroupId)),
|
||||
)
|
||||
// ─── 派生标签已下沉到链接级(origin_good_tags):在「关联原产品」成员行内配置 ───
|
||||
|
||||
function toggleTagInSelection(tagId: string): void {
|
||||
const idx = selectedTagIds.value.indexOf(tagId)
|
||||
@@ -1959,9 +2036,6 @@ onMounted(() => loadAll())
|
||||
<el-form-item label="图片">
|
||||
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<div class="derived-tags-note">标签无需手动选择:保存后由系统按链接名称自动解析(印花数量 / 工艺 / 物流)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">取消</el-button>
|
||||
@@ -2006,62 +2080,75 @@ onMounted(() => loadAll())
|
||||
|
||||
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
||||
<el-dialog v-model="editVisible" :title="cleanLinkName(editGood?.goodName) || '编辑商品'" width="900px" destroy-on-close>
|
||||
<!-- Origin product reference -->
|
||||
<div v-if="editGood?.originGood" class="edit-og-ref">
|
||||
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
|
||||
<div class="edit-og-meta">
|
||||
<div class="edit-og-label">{{ editIsCustom ? '自定义商品' : '关联原产品' }}</div>
|
||||
<div class="edit-og-name" :title="editGood.originGood.goodName ?? undefined">{{ cleanLinkName(editGood.originGood.goodName) }}</div>
|
||||
<div class="edit-og-sub">{{ editIsCustom ? '自定义 ID' : 'SDS ID' }}: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
||||
<div class="edit-og-status">
|
||||
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
|
||||
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
|
||||
</el-tag>
|
||||
<span v-if="editGood.originGood.detailSyncedAt">
|
||||
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
|
||||
<div v-if="!editIsCustom" class="edit-merged-box">
|
||||
<div class="edit-merged-title">
|
||||
关联原产品
|
||||
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。展开成员可查看详情并修正标签。">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<span v-if="editFamilyCode" class="edit-family-code">族 {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
|
||||
</div>
|
||||
<div class="edit-merged-list">
|
||||
<template v-for="row in familyRows" :key="row.key">
|
||||
<div class="edit-merged-item" :class="{ primary: row.role === 'primary' }">
|
||||
<el-icon
|
||||
class="member-expand-toggle"
|
||||
:class="{ 'is-expanded': expandedMemberKeys.has(row.key) }"
|
||||
@click="toggleMemberExpand(row.key)"
|
||||
><ArrowDown /></el-icon>
|
||||
<span class="edit-merged-tag" :class="{ sub: row.role !== 'primary' }">{{ row.role === 'primary' ? '主' : '族' }}</span>
|
||||
<span class="edit-merged-name" :title="row.goodName">{{ cleanLinkName(row.goodName) }}</span>
|
||||
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
|
||||
<template v-if="row.tags.length">
|
||||
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview" :title="'按名称解析预览,展开可配置'">{{ c }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
|
||||
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
|
||||
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
|
||||
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
|
||||
<div v-if="row.role !== 'primary'" class="edit-merged-actions">
|
||||
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(row)">设为主链接</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedMemberKeys.has(row.key)" class="member-detail-panel">
|
||||
<div class="member-detail-grid">
|
||||
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
|
||||
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? `¥${row.goodPrice}` : '-' }}</span>
|
||||
<span class="md-label">SKU 数</span><span class="md-val">{{ row.variantCount }}</span>
|
||||
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
|
||||
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
|
||||
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
|
||||
<span class="md-label">原始名称</span><span class="md-val" :title="row.goodName">{{ row.goodName }}</span>
|
||||
</div>
|
||||
<div class="member-tags-edit">
|
||||
<span class="md-label">标签</span>
|
||||
<el-select
|
||||
v-model="memberTagEdits[row.key]"
|
||||
multiple filterable size="small"
|
||||
placeholder="选择标签"
|
||||
style="flex:1"
|
||||
>
|
||||
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
||||
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" :loading="memberTagSaving === row.key" @click="saveMemberTags(row)">保存标签</el-button>
|
||||
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.key" @click="resetMemberTags(row)">恢复自动</el-button>
|
||||
<el-button
|
||||
v-if="!editIsCustom"
|
||||
type="primary"
|
||||
plain
|
||||
v-if="row.role === 'primary'"
|
||||
size="small"
|
||||
:icon="Refresh"
|
||||
:loading="detailSyncing"
|
||||
@click="handleSyncOneDetail"
|
||||
>同步详情</el-button>
|
||||
</div>
|
||||
<div v-if="!editIsCustom" class="edit-merged-box">
|
||||
<div class="edit-merged-title">
|
||||
关联原产品(族成员)
|
||||
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<span v-if="editFamilyCode" class="edit-family-code">族 {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length + 1 }} 条链接</span>
|
||||
</div>
|
||||
<div class="edit-merged-list">
|
||||
<div class="edit-merged-item primary">
|
||||
<span class="edit-merged-tag">主</span>
|
||||
<span class="edit-merged-name" :title="editGood?.originGood?.goodName ?? undefined">{{ cleanLinkName(editGood?.originGood?.goodName) }}</span>
|
||||
<span v-if="deriveLinkTagNames(editGood?.originGood?.goodName).length" class="member-chips">
|
||||
<span v-for="c in deriveLinkTagNames(editGood?.originGood?.goodName)" :key="c" class="member-chip">{{ c }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="m in editFamilyMembers" :key="m.id" class="edit-merged-item">
|
||||
<span class="edit-merged-tag sub">族</span>
|
||||
<span class="edit-merged-name" :title="m.goodName">{{ cleanLinkName(m.goodName) }}</span>
|
||||
<span v-if="deriveLinkTagNames(m.goodName).length" class="member-chips">
|
||||
<span v-for="c in deriveLinkTagNames(m.goodName)" :key="c" class="member-chip">{{ c }}</span>
|
||||
</span>
|
||||
<div class="edit-merged-actions">
|
||||
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(m)">设为主链接</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeFamilyMember(m)">移除出族</el-button>
|
||||
<div class="derived-tags-note">
|
||||
标签按链接名称自动解析(印花数量 / 工艺 / 物流);解析不对可直接修改,保存后自动同步不再覆盖该链接,「恢复自动」回到按名称派生。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中…</div>
|
||||
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族:配置合并时自动成族</div>
|
||||
</div>
|
||||
@@ -2087,15 +2174,6 @@ onMounted(() => loadAll())
|
||||
<el-form-item label="分类">
|
||||
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<div class="derived-tags-row derived-tags-readonly">
|
||||
<template v-if="editAutoTags.length">
|
||||
<el-tag v-for="t in editAutoTags" :key="t.id" size="small" class="derived-tag">{{ t.tagName }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="derived-tags-note">暂无自动标签</span>
|
||||
<span class="derived-tags-note">由链接名称自动解析(印花数量 / 工艺 / 物流),不可手动编辑</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editIsCustom" label="基础价格">
|
||||
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
|
||||
</el-form-item>
|
||||
@@ -2181,8 +2259,19 @@ onMounted(() => loadAll())
|
||||
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-table v-else :data="editVariants" border max-height="320">
|
||||
<template v-else>
|
||||
<div class="sku-dims-note">价格由 物流 × 工艺 × 印花数量 × 尺码 × 颜色 决定(族内同组合取最低价,人工改价走族价格覆盖)</div>
|
||||
<el-table :data="editVariants" border max-height="320">
|
||||
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
||||
<el-table-column label="物流" width="110">
|
||||
<template #default>{{ editLinkDims.logistics || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工艺" width="100">
|
||||
<template #default>{{ editLinkDims.crafts.join(' / ') || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="印花数量" width="100">
|
||||
<template #default>{{ editLinkDims.printCount || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sizeName" label="尺码" width="90" />
|
||||
<el-table-column prop="colorName" label="颜色" width="100" />
|
||||
<el-table-column prop="price" label="价格" width="100" />
|
||||
@@ -2192,6 +2281,7 @@ onMounted(() => loadAll())
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
|
||||
@@ -2514,16 +2604,6 @@ onMounted(() => loadAll())
|
||||
|
||||
.origin-ref { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.edit-og-ref {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px; background: #f5f7fa; border-radius: 8px;
|
||||
}
|
||||
.edit-og-img { width: 48px; height: 48px; border-radius: 6px; flex-shrink: 0; }
|
||||
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
|
||||
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
|
||||
.edit-og-meta { flex: 1; min-width: 0; }
|
||||
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
|
||||
.edit-merged-box { margin-top: 12px; }
|
||||
.edit-merged-title {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
@@ -2562,6 +2642,30 @@ onMounted(() => loadAll())
|
||||
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
|
||||
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
|
||||
}
|
||||
.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); }
|
||||
.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; }
|
||||
.member-manual-flag {
|
||||
font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px;
|
||||
background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0;
|
||||
}
|
||||
.member-expand-toggle {
|
||||
cursor: pointer; flex-shrink: 0; color: #909399;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.member-expand-toggle.is-expanded { transform: rotate(180deg); }
|
||||
.member-detail-panel {
|
||||
background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px;
|
||||
padding: 10px 12px; margin: -2px 0 2px 30px;
|
||||
}
|
||||
.member-detail-grid {
|
||||
display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
|
||||
gap: 4px 10px; font-size: 12px; margin-bottom: 8px;
|
||||
}
|
||||
.md-label { color: #909399; }
|
||||
.md-val { color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.member-tags-edit { display: flex; align-items: center; gap: 8px; }
|
||||
.member-tags-edit .md-label { flex-shrink: 0; }
|
||||
.sku-dims-note { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
|
||||
.edit-merged-tag {
|
||||
padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px;
|
||||
background: var(--el-color-primary); color: #fff; flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "origin_goods" ADD COLUMN "tags_manual" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "origin_good_tags" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT NOT NULL,
|
||||
"manual" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "origin_good_tags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "origin_good_tags_tag_id_idx" ON "origin_good_tags"("tag_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "origin_good_tags_origin_good_id_tag_id_key" ON "origin_good_tags"("origin_good_id", "tag_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -43,6 +43,9 @@ model OriginGood {
|
||||
detail OriginGoodDetail?
|
||||
variants OriginGoodVariant[]
|
||||
mergedIntoGoods GoodOriginGood[]
|
||||
// 链接级标签:自动派生(manual=false)或人工接管(tagsManual=true 后全部 manual)
|
||||
tagsManual Boolean @default(false) @map("tags_manual")
|
||||
originGoodTags OriginGoodTag[]
|
||||
|
||||
@@index([sdsCategoryId])
|
||||
@@index([source])
|
||||
@@ -53,6 +56,23 @@ model OriginGood {
|
||||
@@map("origin_goods")
|
||||
}
|
||||
|
||||
// ---------- OriginGood-Tag Junction(链接级标签:自动派生 + 人工修正) ----------
|
||||
model OriginGoodTag {
|
||||
id BigInt @id @default(autoincrement())
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
tagId BigInt @map("tag_id")
|
||||
// true = 人工配置(自动同步永不覆盖);false = 按链接名称自动派生
|
||||
manual Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@unique([originGoodId, tagId])
|
||||
@@index([tagId])
|
||||
@@map("origin_good_tags")
|
||||
}
|
||||
|
||||
// ---------- Origin Good Details (cached from SDS /products/{id}) ----------
|
||||
model OriginGoodDetail {
|
||||
originGoodId BigInt @id @map("origin_good_id")
|
||||
@@ -182,6 +202,7 @@ model Tag {
|
||||
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
originGoodTags OriginGoodTag[]
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
@@index([tagGroupId])
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateOriginGoodTagsDto {
|
||||
@ApiProperty({ description: '链接标签 id 全量集合(人工接管)', type: [Number] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(50)
|
||||
@IsNumber({}, { each: true })
|
||||
@Type(() => Number)
|
||||
tagIds: number[];
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
import { UpdateOriginGoodTagsDto } from './dto/update-origin-good-tags.dto';
|
||||
|
||||
@ApiTags('origin-goods')
|
||||
@ApiBearerAuth()
|
||||
@@ -24,4 +25,22 @@ export class OriginGoodsController {
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id/tags')
|
||||
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
|
||||
getTags(@Param('id') id: string) {
|
||||
return this.service.getTags(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/tags')
|
||||
@ApiOperation({ summary: '人工接管链接标签(全量替换,自动同步不再覆盖)' })
|
||||
updateTags(@Param('id') id: string, @Body() dto: UpdateOriginGoodTagsDto) {
|
||||
return this.service.updateTags(BigInt(id), dto.tagIds);
|
||||
}
|
||||
|
||||
@Delete(':id/tags')
|
||||
@ApiOperation({ summary: '恢复自动派生(清掉人工标签)' })
|
||||
resetTags(@Param('id') id: string) {
|
||||
return this.service.resetTags(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OriginGoodsController } from './origin-goods.controller';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { ProductFamiliesModule } from '../product-families/product-families.module';
|
||||
|
||||
@Module({
|
||||
imports: [ProductFamiliesModule],
|
||||
controllers: [OriginGoodsController],
|
||||
providers: [OriginGoodsService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('OriginGoodsService', () => {
|
||||
@@ -10,7 +11,7 @@ describe('OriginGoodsService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [OriginGoodsService, PrismaService],
|
||||
providers: [OriginGoodsService, FamilyRecomputeService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
||||
export interface OriginGoodTagItem {
|
||||
id: string;
|
||||
tagName: string;
|
||||
tagColor: string | null;
|
||||
tagFontColor: string | null;
|
||||
manual: boolean;
|
||||
}
|
||||
|
||||
export interface OriginGoodTagsResult {
|
||||
tagsManual: boolean;
|
||||
tags: OriginGoodTagItem[];
|
||||
}
|
||||
|
||||
export interface PaginatedOriginGoods {
|
||||
items: Array<{
|
||||
id: string;
|
||||
@@ -67,7 +86,94 @@ export interface OriginGoodsTreeResponse {
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly familyRecompute: FamilyRecomputeService,
|
||||
) {}
|
||||
|
||||
/** 链接当前标签(含 manual 标记) */
|
||||
async getTags(id: bigint): Promise<OriginGoodTagsResult> {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
originGoodTags: {
|
||||
include: {
|
||||
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
|
||||
},
|
||||
orderBy: { tagId: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||
return {
|
||||
tagsManual: og.tagsManual,
|
||||
tags: og.originGoodTags.map((r) => ({
|
||||
id: r.tag.id.toString(),
|
||||
tagName: r.tag.tagName,
|
||||
tagColor: r.tag.tagColor,
|
||||
tagFontColor: r.tag.tagFontColor,
|
||||
manual: r.manual,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖),
|
||||
* 并把有效标签镜像到该链接名下的商品。
|
||||
*/
|
||||
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||
const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
|
||||
Number(a - b),
|
||||
);
|
||||
if (uniqueIds.length) {
|
||||
const count = await this.prisma.tag.count({ where: { id: { in: uniqueIds } } });
|
||||
if (count !== uniqueIds.length) {
|
||||
throw new BadRequestException('存在无效标签');
|
||||
}
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
||||
...(!uniqueIds.length
|
||||
? []
|
||||
: [
|
||||
this.prisma.originGoodTag.createMany({
|
||||
data: uniqueIds.map((tagId) => ({
|
||||
originGoodId: id,
|
||||
tagId,
|
||||
manual: true,
|
||||
})),
|
||||
}),
|
||||
]),
|
||||
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
||||
]);
|
||||
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
||||
return this.getTags(id);
|
||||
}
|
||||
|
||||
/** 恢复自动:清掉全部标签行(含人工行),回到按链接名称派生 */
|
||||
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, familyId: true },
|
||||
});
|
||||
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
||||
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
|
||||
]);
|
||||
if (og.familyId) {
|
||||
// 族内链接:整族重派生 + 商品镜像
|
||||
await this.familyRecompute.syncFamilyTags(og.familyId);
|
||||
} else {
|
||||
await this.familyRecompute.refreshLinkTags(id);
|
||||
}
|
||||
return this.getTags(id);
|
||||
}
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
|
||||
@@ -300,20 +300,93 @@ export class FamilyRecomputeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 族 → 商品标签同步:标签与「产品链接」一一对应,按每条链接自身的名称解析
|
||||
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
|
||||
* 自动补建;仅更新发生变化的商品,幂等。
|
||||
* 族 → 标签同步(标签与「产品链接」一一对应):
|
||||
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
|
||||
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
|
||||
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
|
||||
* 仅更新发生变化的行,幂等。
|
||||
*/
|
||||
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
|
||||
async syncFamilyTags(
|
||||
familyId: bigint,
|
||||
): Promise<{ goodsUpdated: number; linksUpdated: number }> {
|
||||
const tagMap = await this.ensureDerivedTagMap();
|
||||
const family = await this.prisma.productFamily.findUnique({
|
||||
where: { id: familyId },
|
||||
include: {
|
||||
originGoods: {
|
||||
where: { delisted: false },
|
||||
include: { originGoodTags: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!family) return { goodsUpdated: 0, linksUpdated: 0 };
|
||||
|
||||
// 1) 链接级派生
|
||||
let linksUpdated = 0;
|
||||
for (const og of family.originGoods) {
|
||||
if (og.tagsManual || og.source !== 'SDS') continue;
|
||||
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
|
||||
const autoRows = og.originGoodTags.filter((r) => !r.manual);
|
||||
const currentIds = autoRows.map((r) => r.tagId).sort((a, b) => Number(a - b));
|
||||
const same =
|
||||
derivedIds.length === currentIds.length &&
|
||||
derivedIds.every((id, i) => id === currentIds[i]);
|
||||
if (same) continue;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.originGoodTag.deleteMany({
|
||||
where: { originGoodId: og.id, manual: false },
|
||||
}),
|
||||
...(!derivedIds.length
|
||||
? []
|
||||
: [
|
||||
this.prisma.originGoodTag.createMany({
|
||||
data: derivedIds.map((tagId) => ({
|
||||
originGoodId: og.id,
|
||||
tagId,
|
||||
manual: false,
|
||||
})),
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
linksUpdated += 1;
|
||||
}
|
||||
|
||||
// 2) 商品级镜像:派生写入后重新读取链接标签(上面的 include 是派生前快照)
|
||||
const familyOgIds = family.originGoods.map((o) => o.id);
|
||||
const freshRows = familyOgIds.length
|
||||
? await this.prisma.originGoodTag.findMany({
|
||||
where: { originGoodId: { in: familyOgIds } },
|
||||
})
|
||||
: [];
|
||||
const tagsByOg = new Map<string, bigint[]>();
|
||||
for (const r of freshRows) {
|
||||
const key = r.originGoodId.toString();
|
||||
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
|
||||
}
|
||||
|
||||
const goods = await this.prisma.good.findMany({
|
||||
where: { familyId },
|
||||
include: {
|
||||
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
||||
originGood: { select: { goodName: true, source: true } },
|
||||
originGood: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!goods.length) return { goodsUpdated: 0 };
|
||||
const strayOgIds = [
|
||||
...new Set(
|
||||
goods
|
||||
.map((g) => g.originGood?.id.toString())
|
||||
.filter((id): id is string => !!id && !tagsByOg.has(id)),
|
||||
),
|
||||
];
|
||||
if (strayOgIds.length) {
|
||||
const rows = await this.prisma.originGoodTag.findMany({
|
||||
where: { originGoodId: { in: strayOgIds.map((v) => BigInt(v)) } },
|
||||
});
|
||||
for (const r of rows) {
|
||||
const key = r.originGoodId.toString();
|
||||
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = await this.prisma.tagGroup.findMany({
|
||||
select: { id: true, groupName: true },
|
||||
@@ -324,18 +397,14 @@ export class FamilyRecomputeService {
|
||||
|
||||
let goodsUpdated = 0;
|
||||
for (const good of goods) {
|
||||
// 保留:非自动分组的既有标签(人工管理)∪ 本链接名称的派生标签
|
||||
const linkTagIds = good.originGood
|
||||
? (tagsByOg.get(good.originGood.id.toString()) ?? [])
|
||||
: [];
|
||||
// 保留:非自动分组的既有标签(人工管理)∪ 链接有效标签
|
||||
const keep = good.goodTags
|
||||
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
||||
.map((gt) => gt.tagId);
|
||||
const derivedNames =
|
||||
good.originGood && good.originGood.source === 'SDS'
|
||||
? deriveLinkTagNames(good.originGood.goodName)
|
||||
: [];
|
||||
const derivedIds = derivedNames
|
||||
.map((name) => tagMap.get(name))
|
||||
.filter((id): id is bigint => id !== undefined);
|
||||
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
|
||||
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
|
||||
Number(a - b),
|
||||
);
|
||||
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
|
||||
@@ -354,7 +423,94 @@ export class FamilyRecomputeService {
|
||||
]);
|
||||
goodsUpdated += 1;
|
||||
}
|
||||
return { goodsUpdated };
|
||||
return { goodsUpdated, linksUpdated };
|
||||
}
|
||||
|
||||
/** 单链接:未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品 */
|
||||
async refreshLinkTags(ogId: bigint): Promise<void> {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: ogId },
|
||||
include: { originGoodTags: true },
|
||||
});
|
||||
if (!og) return;
|
||||
if (!og.tagsManual && og.source === 'SDS') {
|
||||
const tagMap = await this.ensureDerivedTagMap();
|
||||
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.originGoodTag.deleteMany({
|
||||
where: { originGoodId: og.id, manual: false },
|
||||
}),
|
||||
...(!derivedIds.length
|
||||
? []
|
||||
: [
|
||||
this.prisma.originGoodTag.createMany({
|
||||
data: derivedIds.map((tagId) => ({
|
||||
originGoodId: og.id,
|
||||
tagId,
|
||||
manual: false,
|
||||
})),
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
await this.mirrorLinkTagsToGoods(og.id);
|
||||
}
|
||||
|
||||
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
|
||||
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
|
||||
const rows = await this.prisma.originGoodTag.findMany({
|
||||
where: { originGoodId: ogId },
|
||||
select: { tagId: true },
|
||||
});
|
||||
const linkTagIds = rows.map((r) => r.tagId);
|
||||
const goods = await this.prisma.good.findMany({
|
||||
where: { originGoodId: ogId },
|
||||
include: {
|
||||
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
||||
},
|
||||
});
|
||||
if (!goods.length) return;
|
||||
const groups = await this.prisma.tagGroup.findMany({
|
||||
select: { id: true, groupName: true },
|
||||
});
|
||||
const autoGroupIds = new Set(
|
||||
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
|
||||
);
|
||||
for (const good of goods) {
|
||||
const keep = good.goodTags
|
||||
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
||||
.map((gt) => gt.tagId);
|
||||
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
|
||||
Number(a - b),
|
||||
);
|
||||
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
|
||||
const same =
|
||||
target.length === current.length && target.every((id, i) => id === current[i]);
|
||||
if (same) continue;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }),
|
||||
...(!target.length
|
||||
? []
|
||||
: [
|
||||
this.prisma.goodTag.createMany({
|
||||
data: target.map((tagId) => ({ goodId: good.id, tagId })),
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDerivedIds(
|
||||
name: string | null | undefined,
|
||||
tagMap: Map<string, bigint>,
|
||||
): bigint[] {
|
||||
return [
|
||||
...new Set(
|
||||
deriveLinkTagNames(name)
|
||||
.map((n) => tagMap.get(n))
|
||||
.filter((id): id is bigint => id !== undefined),
|
||||
),
|
||||
].sort((a, b) => Number(a - b));
|
||||
}
|
||||
|
||||
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FamilyRecomputeService } from './family-recompute.service';
|
||||
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** 族 → 商品标签自动同步:标签按每条链接自身名称派生(印花数量/工艺/物流) */
|
||||
describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
let service: FamilyRecomputeService;
|
||||
/**
|
||||
* 标签与「产品链接」一一对应:
|
||||
* 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tags(manual=false);
|
||||
* 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签);
|
||||
* 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。
|
||||
*/
|
||||
describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
||||
let recompute: FamilyRecomputeService;
|
||||
let originGoods: OriginGoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const ids = {
|
||||
@@ -19,9 +26,10 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [FamilyRecomputeService, PrismaService],
|
||||
providers: [FamilyRecomputeService, OriginGoodsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(FamilyRecomputeService);
|
||||
recompute = moduleRef.get(FamilyRecomputeService);
|
||||
originGoods = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
@@ -34,6 +42,7 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
afterAll(async () => {
|
||||
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
|
||||
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
||||
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: ids.originGood } } });
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
||||
@@ -43,23 +52,22 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function tagNames(goodId: bigint): Promise<string[]> {
|
||||
const rows = await prisma.goodTag.findMany({
|
||||
where: { goodId },
|
||||
include: { tag: true },
|
||||
});
|
||||
async function goodTagNames(goodId: bigint): Promise<string[]> {
|
||||
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
|
||||
return rows.map((r) => r.tag.tagName);
|
||||
}
|
||||
|
||||
it('每个商品的标签只来自自己的链接名称;旧自动组标签被剔除;人工分组保留', async () => {
|
||||
async function linkTagNames(ogId: bigint): Promise<string[]> {
|
||||
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } });
|
||||
return rows.map((r) => r.tag.tagName);
|
||||
}
|
||||
|
||||
it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => {
|
||||
const styleTag = await prisma.tag.create({ data: { tagName: `潮流${stamp}`, tagGroupId: ids.group[0] } });
|
||||
ids.tag.push(styleTag.id);
|
||||
const legacyPositionTag = await prisma.tag.findFirst({
|
||||
where: { tagName: '单面印', tagGroup: { groupName: { contains: '印刷位置' } } },
|
||||
});
|
||||
const legacyBaoyou = await prisma.tag.findFirst({
|
||||
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
||||
});
|
||||
|
||||
const og1 = await prisma.originGood.create({
|
||||
data: {
|
||||
@@ -106,59 +114,83 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
},
|
||||
});
|
||||
ids.good.push(good1.id, good2.id);
|
||||
// 预置:人工分组标签(保留)+ 旧自动组标签(应被剔除)
|
||||
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: styleTag.id } });
|
||||
if (legacyPositionTag) {
|
||||
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
||||
}
|
||||
if (legacyBaoyou) {
|
||||
await prisma.goodTag.create({ data: { goodId: good2.id, tagId: legacyBaoyou.id } });
|
||||
}
|
||||
|
||||
const r1 = await service.syncFamilyTags(family.id);
|
||||
const r1 = await recompute.syncFamilyTags(family.id);
|
||||
expect(r1.linksUpdated).toBe(2);
|
||||
expect(r1.goodsUpdated).toBe(2);
|
||||
|
||||
const names1 = await tagNames(good1.id);
|
||||
expect(names1).toContain('单面印花');
|
||||
expect(names1).toContain('烫画');
|
||||
expect(names1).toContain('包邮');
|
||||
expect(names1).toContain(`潮流${stamp}`);
|
||||
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印/光板/不包邮
|
||||
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印', '光板']);
|
||||
|
||||
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
|
||||
const names1 = await goodTagNames(good1.id);
|
||||
expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`]));
|
||||
expect(names1).not.toContain('单面印');
|
||||
expect(names1).not.toContain('双面印花');
|
||||
expect(names1).not.toContain('不包邮');
|
||||
expect(await goodTagNames(good2.id)).toEqual(['不包邮', '不打印', '光板']);
|
||||
|
||||
// og2 名称含 不打印 + 光板(物流备注)→ 两个工艺标签,无默认烫画、无印花数量标签
|
||||
const names2 = await tagNames(good2.id);
|
||||
expect(names2).toContain('不打印');
|
||||
expect(names2).toContain('光板');
|
||||
expect(names2).toContain('不包邮');
|
||||
expect(names2).not.toContain('烫画');
|
||||
expect(names2).not.toContain('包邮');
|
||||
expect(names2).not.toContain('单面印花');
|
||||
|
||||
// 幂等:无变化不写
|
||||
const r2 = await service.syncFamilyTags(family.id);
|
||||
// 幂等
|
||||
const r2 = await recompute.syncFamilyTags(family.id);
|
||||
expect(r2.linksUpdated).toBe(0);
|
||||
expect(r2.goodsUpdated).toBe(0);
|
||||
|
||||
// 链接名称变化 → good1 标签跟随新名称
|
||||
await prisma.originGood.update({
|
||||
where: { id: og1.id },
|
||||
data: { goodName: `美国(不包邮)180g纯棉T恤成人款-DG${stamp}-双面印花` },
|
||||
});
|
||||
await service.syncFamilyTags(family.id);
|
||||
const names3 = await tagNames(good1.id);
|
||||
expect(names3).toContain('双面印花');
|
||||
expect(names3).toContain('烫画');
|
||||
expect(names3).toContain('不包邮');
|
||||
expect(names3).toContain(`潮流${stamp}`);
|
||||
expect(names3).not.toContain('单面印花');
|
||||
expect(names3).not.toContain('包邮');
|
||||
});
|
||||
|
||||
it('自定义来源的成员不派生标签,仅剔除自动组标签', async () => {
|
||||
const realBaoyou = await prisma.tag.findFirst({
|
||||
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
||||
it('人工接管链接标签后自动同步不覆盖,商品跟随;恢复自动回到派生', async () => {
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `tagsync-manual-${stamp}`,
|
||||
goodName: `美国(包邮)卫衣-DGM${stamp}-单面印花`,
|
||||
logisticsLabel: '包邮',
|
||||
craftLabel: '单面印花',
|
||||
},
|
||||
});
|
||||
ids.originGood.push(og.id);
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `人工标签族-${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
ids.family.push(family.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og.id,
|
||||
familyId: family.id,
|
||||
countryId: ids.country[0],
|
||||
categoryId: ids.category[0],
|
||||
goodName: `人工标签商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
ids.good.push(good.id);
|
||||
|
||||
await recompute.syncFamilyTags(family.id);
|
||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||
|
||||
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
|
||||
const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } });
|
||||
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
|
||||
const result = await originGoods.updateTags(og.id, [Number(zhpena!.id), Number(baoyou!.id)]);
|
||||
expect(result.tagsManual).toBe(true);
|
||||
expect(result.tags.map((t) => t.tagName)).toEqual(['包邮', '直喷']);
|
||||
expect(result.tags.every((t) => t.manual)).toBe(true);
|
||||
// 商品镜像跟随人工修正
|
||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
||||
|
||||
// 再次族同步:人工行不被覆盖
|
||||
await recompute.syncFamilyTags(family.id);
|
||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']);
|
||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
||||
|
||||
// 恢复自动 → 回到名称派生结果
|
||||
const reset = await originGoods.resetTags(og.id);
|
||||
expect(reset.tagsManual).toBe(false);
|
||||
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '烫画', '单面印花']);
|
||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||
});
|
||||
|
||||
it('自定义来源链接不派生标签,仅镜像人工配置', async () => {
|
||||
const ogCustom = await prisma.originGood.create({
|
||||
data: {
|
||||
source: 'CUSTOM',
|
||||
@@ -171,10 +203,7 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
|
||||
});
|
||||
ids.family.push(family.id);
|
||||
await prisma.originGood.update({
|
||||
where: { id: ogCustom.id },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
await prisma.originGood.update({ where: { id: ogCustom.id }, data: { familyId: family.id } });
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: ogCustom.id,
|
||||
@@ -185,14 +214,14 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
},
|
||||
});
|
||||
ids.good.push(good.id);
|
||||
if (realBaoyou) {
|
||||
await prisma.goodTag.create({ data: { goodId: good.id, tagId: realBaoyou.id } });
|
||||
}
|
||||
|
||||
const r = await service.syncFamilyTags(family.id);
|
||||
expect(r.goodsUpdated).toBe(realBaoyou ? 1 : 0);
|
||||
const names = await tagNames(good.id);
|
||||
expect(names).not.toContain('包邮');
|
||||
expect(names).not.toContain('烫画');
|
||||
const r = await recompute.syncFamilyTags(family.id);
|
||||
expect(r.linksUpdated).toBe(0);
|
||||
expect(await goodTagNames(good.id)).toEqual([]);
|
||||
|
||||
// 自定义链接同样可人工配置标签
|
||||
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
|
||||
await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]);
|
||||
expect(await goodTagNames(good.id)).toEqual(['包邮']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,17 +21,29 @@ function codeFromCategoryName(categoryName: string | null | undefined): string |
|
||||
}
|
||||
|
||||
const FAMILY_INCLUDE = { originGoods: {
|
||||
orderBy: { id: 'asc' as const },
|
||||
select: {
|
||||
id: true,
|
||||
sdsGoodId: true,
|
||||
goodName: true,
|
||||
goodImage: true,
|
||||
goodPrice: true,
|
||||
source: true,
|
||||
delisted: true,
|
||||
skuCode: true,
|
||||
logisticsLabel: true,
|
||||
craftLabel: true,
|
||||
warehouseLabel: true,
|
||||
tagsManual: true,
|
||||
originGoodTags: {
|
||||
orderBy: { tagId: 'asc' as const },
|
||||
select: {
|
||||
id: true,
|
||||
manual: true,
|
||||
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
priceOverrides: true,
|
||||
|
||||
@@ -372,8 +372,9 @@ describe('PublicService', () => {
|
||||
const groups = await service.getTagGroups();
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
const names = groups.map((g) => g.groupName);
|
||||
// 链接级派生标签落地后,商品挂在 物流渠道/印刷工艺/印花数量 三组
|
||||
expect(names).toContain('物流渠道');
|
||||
expect(names).toContain('印刷位置');
|
||||
expect(names).toContain('印花数量');
|
||||
expect(names).toContain('印刷工艺');
|
||||
// Sorted by sortOrder
|
||||
const sortOrders = groups.map((g) => g.sortOrder);
|
||||
|
||||
@@ -130,28 +130,31 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
|
||||
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);
|
||||
- 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」:
|
||||
显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员——
|
||||
操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,主/族成员行可**逐个展开**——
|
||||
查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库 / 原始名称)、
|
||||
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生);成员行仍支持
|
||||
`设为主链接` / `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 编辑弹窗 SKU 表新增 **物流 / 工艺 / 印花数量** 三列:价格由
|
||||
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定(族内同组合取最低价,人工改价走族价格覆盖);
|
||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
||||
|
||||
**族派生标签(按链接名称自动解析,2026-08 规则改版)**:
|
||||
**链接级标签(自动派生 + 人工修正,2026-08 规则改版)**:
|
||||
|
||||
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),因此不再按
|
||||
族并集派生,而是按每条链接自身名称解析后写入该链接对应的商品;
|
||||
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),
|
||||
落库在链接级 `origin_good_tags` 表(`manual` 标记人工/派生);商品的自动组标签是其
|
||||
**主链接标签的镜像**;
|
||||
- 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端
|
||||
`utils/origin-name.ts#deriveLinkTagNames` 同构):
|
||||
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(新组「印花数量」);
|
||||
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」);
|
||||
- **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」);
|
||||
都不含 → 默认 `烫画`;
|
||||
- **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中);
|
||||
- 组名匹配「物流/工艺/位置/印花数量」的组视为**自动组**:每次族重算/成员变更/商品创建更新时
|
||||
同步;缺失的组与标签自动补建;旧的自动组标签(如「印刷位置」的单面印/双面印)会被剔除;
|
||||
- 后台商品表单**不再提供标签手输框**:编辑弹窗展示只读的自动标签胶囊,配置弹窗提示
|
||||
「保存后由系统按链接名称自动解析」;后端同样剔除手动传入的自动组标签;
|
||||
- **人工调节接口保留**:`设为主链接` / `移除出族` / 搜索添加成员(`updateMembers`)、
|
||||
价格改价(`/product-families/*/overrides`)——自动组织不对时可手动调整;
|
||||
- 其他分组(如风格类)不受影响,保持人工管理;
|
||||
- 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行;
|
||||
**人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签;
|
||||
缺失的组与标签自动补建;
|
||||
- **人工修正入口**:编辑弹窗成员行展开 → 修改标签 → 保存标签(全量替换为人工行,
|
||||
`PUT /origin-goods/:id/tags`);「恢复自动」清掉人工行回到派生(`DELETE /origin-goods/:id/tags`);
|
||||
- 其他分组(如风格类)不受影响,保持人工管理;自定义链接(无名称可解析)同样支持人工配置;
|
||||
- 实测:`美国(包邮)…-DG001-单面印花` → `包邮 / 烫画 / 单面印花`;
|
||||
`美国(不包邮光板)…-JSA002-不打印` → `不包邮 / 不打印 / 光板`;
|
||||
`美国(不包邮)…-DG501-双面印花` → `不包邮 / 烫画 / 双面印花`。
|
||||
|
||||
@@ -58,7 +58,7 @@ apps/api/
|
||||
│ ├── tags/ # 标签 CRUD(受 JWT 保护)
|
||||
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
|
||||
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
|
||||
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息)
|
||||
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
|
||||
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules)
|
||||
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
|
||||
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
|
||||
@@ -120,6 +120,7 @@ apps/api/
|
||||
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
|
||||
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
|
||||
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT |
|
||||
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
|
||||
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
|
||||
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
|
||||
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
|
||||
|
||||
Reference in New Issue
Block a user