merge: feature/family-link-tags-ui-dev into develop (local only, not pushed)
This commit is contained in:
@@ -360,6 +360,7 @@ export interface OriginGood {
|
||||
sizeRowCount?: number
|
||||
packageRowCount?: number
|
||||
productCode?: string | null
|
||||
family?: { familyId: string; familyCode: string | null; familyName: string } | null
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sameOriginGroup, truncateToProcess } from './origin-name';
|
||||
import {
|
||||
cleanLinkName,
|
||||
deriveLinkTagNames,
|
||||
parseLinkName,
|
||||
sameOriginGroup,
|
||||
truncateToProcess,
|
||||
} from './origin-name';
|
||||
|
||||
describe('truncateToProcess', () => {
|
||||
it('keeps first 3 dash segments (drops warehouse)', () => {
|
||||
@@ -44,3 +50,59 @@ describe('sameOriginGroup', () => {
|
||||
expect(sameOriginGroup('', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLinkName / cleanLinkName', () => {
|
||||
it('parses 国家(物流)品名-SKU-工艺 → 品名 + 型号', () => {
|
||||
expect(parseLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
|
||||
productName: '180g纯棉T恤成人款',
|
||||
skuCode: 'DG001',
|
||||
});
|
||||
expect(cleanLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toBe(
|
||||
'180g纯棉T恤成人款 DG001',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles half-width parens and warehouse suffix', () => {
|
||||
expect(
|
||||
cleanLinkName('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
).toBe('180GT恤成人款 JSA002');
|
||||
});
|
||||
|
||||
it('returns null / raw fallback for manual names without the country(remark) structure', () => {
|
||||
expect(parseLinkName('180g纯棉T恤成人款')).toBeNull();
|
||||
expect(cleanLinkName('180g纯棉T恤成人款')).toBe('180g纯棉T恤成人款');
|
||||
expect(cleanLinkName(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns null when product name after parens is empty', () => {
|
||||
expect(parseLinkName('美国(包邮)-DG001-单面印花')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveLinkTagNames', () => {
|
||||
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
|
||||
expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([
|
||||
'单面印花',
|
||||
'烫画',
|
||||
'包邮',
|
||||
]);
|
||||
});
|
||||
|
||||
it('不打印 + 光板 + 不包邮 → 两工艺标签 + 不包邮', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
).toEqual(['不打印', '光板', '不包邮']);
|
||||
});
|
||||
|
||||
it('双面印花 + 不包邮,且不误命中包邮', () => {
|
||||
expect(deriveLinkTagNames('美国(不包邮)T恤-DG001-双面印花·美东新泽西仓')).toEqual([
|
||||
'双面印花',
|
||||
'烫画',
|
||||
'不包邮',
|
||||
]);
|
||||
});
|
||||
|
||||
it('空名称返回空数组', () => {
|
||||
expect(deriveLinkTagNames(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,3 +18,54 @@ export function sameOriginGroup(
|
||||
const ka = truncateToProcess(a);
|
||||
return ka !== '' && ka === truncateToProcess(b);
|
||||
}
|
||||
|
||||
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
|
||||
export interface ParsedLinkName {
|
||||
productName: string;
|
||||
skuCode: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。
|
||||
* 与 api 端 origin-name.parser.ts 同构;解析失败(无「国家(备注)」前缀结构)返回 null,
|
||||
* 调用方应回退显示原始名称 —— 手动维护的品名不走该解析。
|
||||
*/
|
||||
export function parseLinkName(name: string | null | undefined): ParsedLinkName | null {
|
||||
if (!name) return null;
|
||||
const segs = name.split('-').map((s) => s.trim());
|
||||
const head = segs[0] ?? '';
|
||||
const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'));
|
||||
if (closeIdx <= 0 || closeIdx === head.length - 1) return null;
|
||||
const productName = head.slice(closeIdx + 1).trim();
|
||||
if (!productName) return null;
|
||||
const skuCode = segs[1] || null;
|
||||
return { productName, skuCode };
|
||||
}
|
||||
|
||||
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */
|
||||
export function cleanLinkName(name: string | null | undefined): string {
|
||||
if (!name) return '';
|
||||
const parsed = parseLinkName(name);
|
||||
if (!parsed) return name;
|
||||
return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName;
|
||||
}
|
||||
|
||||
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||
|
||||
/**
|
||||
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
|
||||
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画;
|
||||
* 物流:不包邮 优先于 包邮。
|
||||
*/
|
||||
export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (!name) return [];
|
||||
const names: string[] = [];
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k));
|
||||
if (craftHits.length > 0) names.push(...craftHits);
|
||||
else names.push('烫画');
|
||||
if (name.includes('不包邮')) names.push('不包邮');
|
||||
else if (name.includes('包邮')) names.push('包邮');
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -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 { truncateToProcess } from '@/utils/origin-name'
|
||||
import { cleanLinkName, deriveLinkTagNames, truncateToProcess } from '@/utils/origin-name'
|
||||
import { productFamiliesApi } from '@/api/product-families'
|
||||
|
||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||
@@ -182,7 +182,9 @@ function goodToNode(g: Good): any {
|
||||
const filteredGoods = computed(() => {
|
||||
let result = allGoods.value
|
||||
if (searchKeyword.value) {
|
||||
result = result.filter(g => g.goodName?.includes(searchKeyword.value))
|
||||
const kw = searchKeyword.value
|
||||
// 同时匹配原始链接名与解析后的「品名 型号」展示名
|
||||
result = result.filter(g => g.goodName?.includes(kw) || cleanLinkName(g.goodName).includes(kw))
|
||||
}
|
||||
if (selectedCountryIds.value.length) {
|
||||
result = result.filter(g => selectedCountryIds.value.includes(g.countryId))
|
||||
@@ -289,13 +291,32 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||
children: [...children, ...goods],
|
||||
}
|
||||
}
|
||||
rightTreeData.value = tree.tree.map(mapCat)
|
||||
const roots = tree.tree.map(mapCat)
|
||||
// 已配置数按族计入:链接成族(或已配置商品)即视为已配置,后序汇总到各级分类
|
||||
function fillConfiguredEff(nodes: any[]): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.isOG) {
|
||||
n.configuredEff = n.configuredCount > 0 || n.familyId ? 1 : 0
|
||||
} else {
|
||||
n.configuredEff = fillConfiguredEff(n.children ?? [])
|
||||
}
|
||||
count += n.configuredEff
|
||||
}
|
||||
return count
|
||||
}
|
||||
fillConfiguredEff(roots)
|
||||
rightTreeData.value = roots
|
||||
}
|
||||
|
||||
function rightFilterNode(_value: string, data: any) {
|
||||
if (data.isOG) {
|
||||
if (showUnconfiguredOnly.value && data.configuredCount > 0) return false
|
||||
if (searchKeyword.value && !data.label.includes(searchKeyword.value)) return false
|
||||
// 「仅未配置」同样把族内链接视为已配置
|
||||
if (showUnconfiguredOnly.value && (data.configuredCount > 0 || data.familyId)) return false
|
||||
if (searchKeyword.value) {
|
||||
const kw = searchKeyword.value
|
||||
if (!data.label.includes(kw) && !cleanLinkName(data.label).includes(kw)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
@@ -930,8 +951,17 @@ function locateInRightTree(originGoodId: string) {
|
||||
}, 250)
|
||||
}
|
||||
|
||||
/** 右侧定位到左侧后短暂闪烁的商品 id(用于行级高亮动画) */
|
||||
const locateFlashGoodId = ref<string>('')
|
||||
|
||||
function locateInLeftTree(originGoodId: string) {
|
||||
const good = allGoods.value.find(g => g.originGoodId === originGoodId)
|
||||
// 链接 → 商品:优先精确匹配该链接配置的商品,其次同族商品(族内任一链接都可定位)
|
||||
const ogNode = findOgNodeById(originGoodId)
|
||||
const familyId = ogNode?.familyId ?? null
|
||||
const good = allGoods.value.find(g => String(g.originGoodId) === String(originGoodId))
|
||||
?? (familyId
|
||||
? allGoods.value.find(g => g.originGood?.family?.familyId === familyId)
|
||||
: null)
|
||||
if (!good) {
|
||||
ElMessage.warning('该原产品尚未配置到官网')
|
||||
return
|
||||
@@ -958,6 +988,9 @@ function locateInLeftTree(originGoodId: string) {
|
||||
}
|
||||
setTimeout(() => {
|
||||
tree.setCurrentKey(targetKey)
|
||||
// 闪烁高亮定位行(2.4s 后自动消失)
|
||||
locateFlashGoodId.value = good.id
|
||||
setTimeout(() => { if (locateFlashGoodId.value === good.id) locateFlashGoodId.value = '' }, 2400)
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.gv-left .el-tree-node.is-current') as HTMLElement
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -1293,27 +1326,14 @@ const groupedTagOptions = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
// ─── 族派生标签:物流/工艺/位置组由族自动同步,表单中只读 ───
|
||||
const isAutoTagGroup = (name: string) => /物流|工艺|位置/.test(name)
|
||||
// ─── 派生标签:物流/工艺/印花数量组由系统按链接名称自动生成,表单中只读 ───
|
||||
const isAutoTagGroup = (name: string) => /物流|工艺|位置|印花数量/.test(name)
|
||||
const autoTagGroupIds = computed(() =>
|
||||
new Set(allTagGroups.value.filter((g) => isAutoTagGroup(g.groupName)).map((g) => g.id)),
|
||||
)
|
||||
const configHasFamily = computed(() => Boolean((configOG.value as any)?.familyId))
|
||||
const configTagOptions = computed(() =>
|
||||
configHasFamily.value
|
||||
? groupedTagOptions.value.filter((g) => g.id === 'ungrouped' || !autoTagGroupIds.value.has(g.id as any))
|
||||
: groupedTagOptions.value,
|
||||
)
|
||||
const editTagOptions = computed(() =>
|
||||
editFamilyId.value
|
||||
? groupedTagOptions.value.filter((g) => g.id === 'ungrouped' || !autoTagGroupIds.value.has(g.id as any))
|
||||
: groupedTagOptions.value,
|
||||
)
|
||||
/** 当前商品的自动组标签(只读展示) */
|
||||
const editDerivedTags = computed(() =>
|
||||
editFamilyId.value
|
||||
? ((editGood.value as any)?.tags ?? []).filter((t: any) => autoTagGroupIds.value.has(t.tagGroupId))
|
||||
: [],
|
||||
/** 当前商品的自动组标签(只读展示,来自其链接名称的解析结果) */
|
||||
const editAutoTags = computed(() =>
|
||||
((editGood.value as any)?.tags ?? []).filter((t: any) => autoTagGroupIds.value.has(t.tagGroupId)),
|
||||
)
|
||||
|
||||
function toggleTagInSelection(tagId: string): void {
|
||||
@@ -1370,19 +1390,6 @@ async function quickCreateCountry(targetForm: () => void) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function quickCreateTag(targetForm: () => void) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入标签名称', '新增标签', {
|
||||
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
|
||||
})
|
||||
if (!value.trim()) return
|
||||
await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any)
|
||||
await reloadTags()
|
||||
targetForm()
|
||||
ElMessage.success('已创建并选中')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function quickCreateTagGroup() {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
|
||||
@@ -1662,6 +1669,7 @@ onMounted(() => loadAll())
|
||||
:data="leftTreeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
:draggable="mode === 'category'"
|
||||
:expand-on-click-node="true"
|
||||
@node-drag-end="onLeftTreeDragEnd"
|
||||
@@ -1684,7 +1692,12 @@ onMounted(() => loadAll())
|
||||
</span>
|
||||
</div>
|
||||
<!-- Good node: two-line with meta -->
|
||||
<div v-else-if="data.isGood" class="good-node" @click="openEdit(data.raw)">
|
||||
<div
|
||||
v-else-if="data.isGood"
|
||||
class="good-node"
|
||||
:class="{ 'good-node--flash': data.goodId === locateFlashGoodId }"
|
||||
@click="openEdit(data.raw)"
|
||||
>
|
||||
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="good-thumb" />
|
||||
<div v-else class="good-thumb-placeholder" />
|
||||
<div class="good-body">
|
||||
@@ -1697,7 +1710,7 @@ onMounted(() => loadAll())
|
||||
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="gt-img" />
|
||||
<div v-else class="gt-img gt-img-empty" />
|
||||
<div class="gt-title-area">
|
||||
<div class="gt-title">{{ data.goodName }}</div>
|
||||
<div class="gt-title">{{ cleanLinkName(data.goodName) }}</div>
|
||||
<div class="gt-country">{{ data.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1715,12 +1728,12 @@ onMounted(() => loadAll())
|
||||
</div>
|
||||
<div v-if="data.originGoodName" class="gt-row">
|
||||
<div class="gt-label">{{ data.isCustom ? '来源' : '原产品' }}</div>
|
||||
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
||||
<div class="gt-val gt-val-ellipsis">{{ cleanLinkName(data.originGoodName) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="good-name">{{ data.goodName }}</span>
|
||||
<span class="good-name" :title="data.goodName">{{ cleanLinkName(data.goodName) }}</span>
|
||||
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -1778,11 +1791,11 @@ onMounted(() => loadAll())
|
||||
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
|
||||
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
|
||||
<div v-else class="og-thumb og-thumb-placeholder" />
|
||||
<span class="og-name">{{ data.label }}</span>
|
||||
<span class="og-name" :title="data.label">{{ cleanLinkName(data.label) }}</span>
|
||||
<span
|
||||
v-if="data.configuredCount > 0"
|
||||
v-if="data.familyId || data.configuredCount > 0"
|
||||
class="og-badge og-badge--ok"
|
||||
title="已配置 {{ data.configuredCount }} 国"
|
||||
:title="data.familyId ? `族 ${data.familyCode || ''} · ${data.familyName || ''}` : `已配置 ${data.configuredCount} 国`"
|
||||
>已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }}</span>
|
||||
<span
|
||||
v-else
|
||||
@@ -1805,12 +1818,12 @@ onMounted(() => loadAll())
|
||||
@click.stop="handleSyncOriginDetail(data)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="data.configuredCount > 0"
|
||||
v-if="data.configuredCount > 0 || data.familyId"
|
||||
size="small" link :icon="Aim" title="定位到官网商品"
|
||||
@click.stop="locateInLeftTree(data.rawId)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="!data.configuredCount"
|
||||
v-if="!data.configuredCount && !data.familyId"
|
||||
size="small" type="primary" link class="og-config-btn"
|
||||
@click.stop="openConfigFromRightTree(data)"
|
||||
>配置</el-button>
|
||||
@@ -1818,8 +1831,8 @@ onMounted(() => loadAll())
|
||||
<div v-else class="og-cat-node">
|
||||
<span>{{ data.label }}</span>
|
||||
<span v-if="data.totalCount" class="og-cat-count">
|
||||
<template v-if="data.configuredCount < data.totalCount">
|
||||
{{ data.configuredCount }}/{{ data.totalCount }}
|
||||
<template v-if="data.configuredEff < data.totalCount">
|
||||
{{ data.configuredEff }}/{{ data.totalCount }}
|
||||
</template>
|
||||
<template v-else>{{ data.totalCount }}</template>
|
||||
</span>
|
||||
@@ -1909,7 +1922,7 @@ onMounted(() => loadAll())
|
||||
<el-dialog v-model="configVisible" title="配置原产品" width="520px" destroy-on-close>
|
||||
<div v-if="configOG" class="config-og-info">
|
||||
<div>
|
||||
<div class="config-og-name">{{ configOG.goodName }}</div>
|
||||
<div class="config-og-name" :title="configOG.goodName">{{ cleanLinkName(configOG.goodName) }}</div>
|
||||
<div class="config-og-meta">SDS ID: {{ configOG.sdsGoodId }}<template v-if="configOG.goodPrice"> · 价格: ¥{{ configOG.goodPrice }}</template></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1929,16 +1942,16 @@ onMounted(() => loadAll())
|
||||
</el-form-item>
|
||||
<el-form-item v-if="configSiblings.length" label="合并同名">
|
||||
<div class="config-merge-box">
|
||||
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。</div>
|
||||
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主链接决定价格与详情。</div>
|
||||
<div class="config-merge-primary">
|
||||
<el-radio-group v-model="configPrimaryId">
|
||||
<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 :value="String(configOG.rawId)">主链接:{{ cleanLinkName(configOG.goodName) }}</el-radio>
|
||||
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ cleanLinkName(s.goodName) }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-checkbox-group v-model="configChecked">
|
||||
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
|
||||
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
|
||||
{{ cleanLinkName(s.goodName) }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
@@ -1947,17 +1960,7 @@ onMounted(() => loadAll())
|
||||
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<div class="select-inline">
|
||||
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||
<el-option-group v-for="g in configTagOptions" :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 text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
|
||||
</div>
|
||||
<div v-if="configHasFamily" class="derived-tags-note">
|
||||
物流 / 工艺 / 印刷位置标签由族成员自动生成,无需手动选择
|
||||
</div>
|
||||
<div class="derived-tags-note">标签无需手动选择:保存后由系统按链接名称自动解析(印花数量 / 工艺 / 物流)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -2002,13 +2005,13 @@ onMounted(() => loadAll())
|
||||
</el-dialog>
|
||||
|
||||
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
|
||||
<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">{{ editGood.originGood.goodName }}</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">
|
||||
@@ -2043,20 +2046,28 @@ onMounted(() => loadAll())
|
||||
<div class="edit-merged-list">
|
||||
<div class="edit-merged-item primary">
|
||||
<span class="edit-merged-tag">主</span>
|
||||
<span>{{ editGood?.originGood?.goodName }}</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>{{ m.goodName }}</span>
|
||||
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(m)">设为主链接</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeFamilyMember(m)">移除出族</el-button>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中…</div>
|
||||
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族:配置合并时自动成族</div>
|
||||
</div>
|
||||
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
|
||||
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
|
||||
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
|
||||
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="cleanLinkName(c.goodName) || c.goodName" :value="String(c.id)"
|
||||
@click="addFamilyMember(c)" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -2077,20 +2088,12 @@ onMounted(() => loadAll())
|
||||
<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="select-inline">
|
||||
<el-select v-model="editForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||
<el-option-group v-for="g in editTagOptions" :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 text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
|
||||
</div>
|
||||
<div v-if="editFamilyId && editDerivedTags.length" class="derived-tags-row">
|
||||
<span class="derived-tags-label">族自动:</span>
|
||||
<el-tag v-for="t in editDerivedTags" :key="t.id" size="small" type="info" class="derived-tag">{{ t.tagName }}</el-tag>
|
||||
</div>
|
||||
<div v-else-if="editFamilyId" class="derived-tags-note">
|
||||
物流 / 工艺 / 印刷位置标签由族成员自动生成,无需手动选择
|
||||
<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="基础价格">
|
||||
@@ -2530,14 +2533,35 @@ onMounted(() => loadAll())
|
||||
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
|
||||
.derived-tags-note { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
||||
.derived-tags-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||
.derived-tags-readonly { margin-top: 0; row-gap: 2px; }
|
||||
.derived-tags-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.derived-tag { pointer-events: none; }
|
||||
/* 左树定位高亮:el-tree 当前节点底色 + 行级闪烁动画 */
|
||||
.gv-left :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
.good-node--flash { animation: good-node-flash 1.2s ease-in-out 2; }
|
||||
@keyframes good-node-flash {
|
||||
0%, 100% { background: transparent; }
|
||||
50% { background: var(--el-color-primary-light-7); box-shadow: inset 0 0 0 1px var(--el-color-primary-light-5); }
|
||||
}
|
||||
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||
.edit-merged-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
|
||||
}
|
||||
.edit-merged-item .el-button { margin-left: auto; }
|
||||
.edit-merged-name {
|
||||
flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* 操作按钮固定右缘:只有 actions 容器吃 margin-left:auto,两个按钮间距固定 */
|
||||
.edit-merged-actions { margin-left: auto; flex-shrink: 0; display: flex; align-items: center; }
|
||||
.edit-merged-actions .el-button + .el-button { margin-left: 4px; }
|
||||
.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; }
|
||||
.member-chip {
|
||||
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
|
||||
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
|
||||
}
|
||||
.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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { isAutoTagGroupName } from '../product-families/auto-tag-rules';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
@@ -619,24 +620,18 @@ export class GoodsService {
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
/** 有族商品:剔除自动组(物流/工艺/位置)标签——它们由族同步管理 */
|
||||
/** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */
|
||||
private async stripAutoGroupTags(
|
||||
tagIds: number[],
|
||||
familyId: bigint | null,
|
||||
): Promise<number[]> {
|
||||
if (!familyId || !tagIds.length) return tagIds;
|
||||
const autoGroups = await this.prisma.tagGroup.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ groupName: { contains: '物流' } },
|
||||
{ groupName: { contains: '工艺' } },
|
||||
{ groupName: { contains: '位置' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
const groups = await this.prisma.tagGroup.findMany({
|
||||
select: { id: true, groupName: true },
|
||||
});
|
||||
if (!autoGroups.length) return tagIds;
|
||||
const autoIds = new Set(autoGroups.map((g) => g.id.toString()));
|
||||
const autoIds = new Set(
|
||||
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
|
||||
);
|
||||
const tags = await this.prisma.tag.findMany({
|
||||
where: { id: { in: tagIds.map((id) => BigInt(id)) } },
|
||||
select: { id: true, tagGroupId: true },
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { deriveLinkTagNames, isAutoTagGroupName } from './auto-tag-rules';
|
||||
|
||||
describe('auto-tag-rules / deriveLinkTagNames', () => {
|
||||
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'),
|
||||
).toEqual(['单面印花', '烫画', '包邮']);
|
||||
});
|
||||
|
||||
it('双面印花 + 不包邮 → 双面印花/烫画/不包邮', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮)180g纯棉T恤成人款-DG001-双面印花·美东新泽西仓'),
|
||||
).toEqual(['双面印花', '烫画', '不包邮']);
|
||||
});
|
||||
|
||||
it('不打印 + 光板(物流备注)+ 不包邮 → 两个工艺标签 + 不包邮,无印花数量标签', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
).toEqual(['不打印', '光板', '不包邮']);
|
||||
});
|
||||
|
||||
it('直喷命中时不给默认烫画', () => {
|
||||
expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['直喷', '包邮']);
|
||||
});
|
||||
|
||||
it('物流备注既无包邮也无不包邮时不下发物流标签', () => {
|
||||
const names = deriveLinkTagNames('美国(快递)卫衣-DG002-单面印花');
|
||||
expect(names).toContain('单面印花');
|
||||
expect(names).toContain('烫画');
|
||||
expect(names).not.toContain('包邮');
|
||||
expect(names).not.toContain('不包邮');
|
||||
});
|
||||
|
||||
it('空名称返回空数组', () => {
|
||||
expect(deriveLinkTagNames(null)).toEqual([]);
|
||||
expect(deriveLinkTagNames('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('先判「不包邮」再判「包邮」,避免子串误命中', () => {
|
||||
const names = deriveLinkTagNames('美国(不包邮)T恤-DG001-单面印花');
|
||||
expect(names).toContain('不包邮');
|
||||
expect(names).not.toContain('包邮');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-tag-rules / isAutoTagGroupName', () => {
|
||||
it('命中真实派生组:物流渠道 / 印刷工艺 / 印花数量 / 印刷位置', () => {
|
||||
expect(isAutoTagGroupName('物流渠道')).toBe(true);
|
||||
expect(isAutoTagGroupName('印刷工艺')).toBe(true);
|
||||
expect(isAutoTagGroupName('印花数量')).toBe(true);
|
||||
expect(isAutoTagGroupName('印刷位置')).toBe(true);
|
||||
});
|
||||
|
||||
it('其他分组不视为自动组', () => {
|
||||
expect(isAutoTagGroupName('潮流')).toBe(false);
|
||||
expect(isAutoTagGroupName('场景')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 链接标签自动派生规则(产品确认版,2026-08-28):
|
||||
*
|
||||
* 标签与「产品链接」一一对应 —— 每条链接因 印花数量/工艺/物流 不同而价格不同
|
||||
* (价格矩阵维度 = 印花数量·工艺 × 物流 × 尺码 × 颜色),因此标签按链接名称解析:
|
||||
*
|
||||
* - 印花数量:名称含「双面印花」→ 双面印花;否则含「单面印花」→ 单面印花;
|
||||
* - 工艺:名称含「直喷」「不打印」「光板」→ 对应标签(可多个);都不含 → 默认「烫画」;
|
||||
* - 物流:名称含「不包邮」→ 不包邮;否则含「包邮」→ 包邮(先判「不包邮」,避免子串误命中)。
|
||||
*
|
||||
* 这些分组(印花数量 / 印刷工艺 / 物流渠道)下的标签为派生数据,不接受手动写入;
|
||||
* 其他分组保持人工管理。
|
||||
*/
|
||||
|
||||
/** 自动(派生)标签组名匹配:命中即视为族托管,手输的这类标签会被剔除 */
|
||||
export function isAutoTagGroupName(name: string): boolean {
|
||||
return /物流|工艺|位置|印花数量/.test(name);
|
||||
}
|
||||
|
||||
export interface DerivedTagGroupSpec {
|
||||
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
|
||||
group: string;
|
||||
/** 组内规则标签(缺失时自动建标) */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** 派生标签所属组及其全部合法取值 */
|
||||
export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [
|
||||
{ group: '物流渠道', tags: ['包邮', '不包邮'] },
|
||||
{ group: '印花数量', tags: ['单面印花', '双面印花'] },
|
||||
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印', '光板'] },
|
||||
];
|
||||
|
||||
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
|
||||
const CRAFT_DEFAULT = '烫画';
|
||||
|
||||
/**
|
||||
* 由链接名称解析标签名集合(不含组信息)。
|
||||
* 确定性、纯函数 —— 同名链接在任何环境派生结果一致。
|
||||
*/
|
||||
export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (!name) return [];
|
||||
const names: string[] = [];
|
||||
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
|
||||
const craftHits = CRAFT_KEYWORDS.filter((keyword) => name.includes(keyword));
|
||||
if (craftHits.length > 0) names.push(...craftHits);
|
||||
else names.push(CRAFT_DEFAULT);
|
||||
|
||||
if (name.includes('不包邮')) names.push('不包邮');
|
||||
else if (name.includes('包邮')) names.push('包邮');
|
||||
|
||||
return names;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DERIVED_TAG_GROUP_SPECS,
|
||||
deriveLinkTagNames,
|
||||
isAutoTagGroupName,
|
||||
} from './auto-tag-rules';
|
||||
|
||||
/**
|
||||
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||
@@ -291,73 +296,45 @@ export class FamilyRecomputeService {
|
||||
}
|
||||
|
||||
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
||||
await this.syncFamilyTags(family.id, members);
|
||||
await this.syncFamilyTags(family.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 族 → 商品标签同步:物流/工艺/印刷位置类标签组(组名含「物流」「工艺」或「位置」,
|
||||
* 与官网筛选维度一致)下的标签由族成员的 logisticsLabel/craftLabel 派生;
|
||||
* 标签名与链接标签存在词尾差异(单面印花 ↔ 单面印),采用前缀匹配并取最长命中。
|
||||
* 其他分组保持人工管理。仅更新发生变化的商品,幂等。
|
||||
* 族 → 商品标签同步:标签与「产品链接」一一对应,按每条链接自身的名称解析
|
||||
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
|
||||
* 自动补建;仅更新发生变化的商品,幂等。
|
||||
*/
|
||||
async syncFamilyTags(
|
||||
familyId: bigint,
|
||||
preloadedMembers?: Member[],
|
||||
): Promise<{ goodsUpdated: number }> {
|
||||
const members =
|
||||
preloadedMembers ??
|
||||
(await this.prisma.originGood.findMany({
|
||||
where: { familyId, delisted: false },
|
||||
select: { logisticsLabel: true, craftLabel: true },
|
||||
}));
|
||||
const labels = members
|
||||
.flatMap((m) => [m.logisticsLabel, m.craftLabel])
|
||||
.filter((l): l is string => !!l);
|
||||
|
||||
const autoGroups = await this.prisma.tagGroup.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ groupName: { contains: '物流' } },
|
||||
{ groupName: { contains: '工艺' } },
|
||||
{ groupName: { contains: '位置' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const autoGroupIds = new Set(autoGroups.map((g) => g.id.toString()));
|
||||
if (!autoGroupIds.size || !labels.length) {
|
||||
return { goodsUpdated: 0 };
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.tag.findMany({
|
||||
where: { tagGroupId: { in: autoGroups.map((g) => g.id) } },
|
||||
select: { id: true, tagName: true },
|
||||
});
|
||||
// 每个链接标签取「最长前缀命中」的标签(避免短名误吃长名场景)
|
||||
const derivedIds = new Set<bigint>();
|
||||
for (const label of new Set(labels)) {
|
||||
let best: { id: bigint; name: string } | null = null;
|
||||
for (const tag of candidates) {
|
||||
if (label === tag.tagName || label.startsWith(tag.tagName)) {
|
||||
if (!best || tag.tagName.length > best.name.length) {
|
||||
best = { id: tag.id, name: tag.tagName };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) derivedIds.add(best.id);
|
||||
}
|
||||
|
||||
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
|
||||
const tagMap = await this.ensureDerivedTagMap();
|
||||
const goods = await this.prisma.good.findMany({
|
||||
where: { familyId },
|
||||
include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } } },
|
||||
include: {
|
||||
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
||||
originGood: { select: { goodName: true, source: true } },
|
||||
},
|
||||
});
|
||||
if (!goods.length) return { goodsUpdated: 0 };
|
||||
|
||||
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()),
|
||||
);
|
||||
|
||||
let goodsUpdated = 0;
|
||||
for (const good of goods) {
|
||||
// 保留:非自动分组的既有标签(人工管理)∪ 派生标签
|
||||
// 保留:非自动分组的既有标签(人工管理)∪ 本链接名称的派生标签
|
||||
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) =>
|
||||
Number(a - b),
|
||||
);
|
||||
@@ -379,4 +356,50 @@ export class FamilyRecomputeService {
|
||||
}
|
||||
return { goodsUpdated };
|
||||
}
|
||||
|
||||
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
|
||||
private async ensureDerivedTagMap(): Promise<Map<string, bigint>> {
|
||||
const map = new Map<string, bigint>();
|
||||
for (const spec of DERIVED_TAG_GROUP_SPECS) {
|
||||
const findGroups = () =>
|
||||
this.prisma.tagGroup.findMany({
|
||||
where: { groupName: { contains: spec.group } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
let group = (await findGroups())[0] ?? null;
|
||||
if (!group) {
|
||||
try {
|
||||
group = await this.prisma.tagGroup.create({ data: { groupName: spec.group } });
|
||||
} catch {
|
||||
group = (await findGroups())[0] ?? null;
|
||||
}
|
||||
}
|
||||
if (!group) continue;
|
||||
|
||||
const tags = await this.prisma.tag.findMany({
|
||||
where: { tagGroupId: group.id, tagName: { in: spec.tags } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
for (const tagName of spec.tags) {
|
||||
const existing = tags.find((t) => t.tagName === tagName);
|
||||
if (existing) {
|
||||
map.set(tagName, existing.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const created = await this.prisma.tag.create({
|
||||
data: { tagGroupId: group.id, tagName },
|
||||
});
|
||||
map.set(tagName, created.id);
|
||||
} catch {
|
||||
const fallback = await this.prisma.tag.findFirst({
|
||||
where: { tagGroupId: group.id, tagName },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (fallback) map.set(tagName, fallback.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { FamilyRecomputeService } from './family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** 族 → 商品标签自动同步(物流/工艺组由成员标签派生,其他分组人工保留) */
|
||||
describe('FamilyRecomputeService.syncFamilyTags', () => {
|
||||
/** 族 → 商品标签自动同步:标签按每条链接自身名称派生(印花数量/工艺/物流) */
|
||||
describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
||||
let service: FamilyRecomputeService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
@@ -15,7 +14,6 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
||||
country: [] as bigint[],
|
||||
category: [] as bigint[],
|
||||
group: [] as bigint[],
|
||||
otherGroup: [] as bigint[],
|
||||
tag: [] as bigint[],
|
||||
};
|
||||
|
||||
@@ -29,47 +27,54 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
||||
|
||||
ids.country.push((await prisma.country.create({ data: { countryName: `标签同步国家-${stamp}` } })).id);
|
||||
ids.category.push((await prisma.category.create({ data: { categoryName: `标签同步分类-${stamp}` } })).id);
|
||||
// 物流组(自动)+ 其他组(人工)
|
||||
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `物流渠道${stamp}`, sortOrder: 1 } })).id);
|
||||
ids.otherGroup.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 2 } })).id);
|
||||
// 人工分组(派生规则之外,标签应保留)
|
||||
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 9 } })).id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.goodTag.deleteMany({ where: { tagId: { in: ids.tag } } });
|
||||
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
|
||||
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
||||
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 } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: [...ids.group, ...ids.otherGroup] } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: ids.group } } });
|
||||
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
|
||||
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function mkTag(name: string, groupId: bigint) {
|
||||
return prisma.tag.create({ data: { tagName: name, tagGroupId: groupId } });
|
||||
async function tagNames(goodId: bigint): Promise<string[]> {
|
||||
const rows = await prisma.goodTag.findMany({
|
||||
where: { goodId },
|
||||
include: { tag: true },
|
||||
});
|
||||
return rows.map((r) => r.tag.tagName);
|
||||
}
|
||||
|
||||
it('族成员标签 → 自动同步到族下商品;人工分组标签保留;成员变化后增量更新', async () => {
|
||||
const tagBaoyou = await mkTag(`包邮${stamp}`, ids.group[0]);
|
||||
const tagDanyin = await mkTag(`单面印花${stamp}`, ids.group[0]);
|
||||
const tagStyle = await mkTag(`潮流${stamp}`, ids.otherGroup[0]);
|
||||
ids.tag.push(tagBaoyou.id, tagDanyin.id, tagStyle.id);
|
||||
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: {
|
||||
sdsGoodId: `tagsync-${stamp}-1`,
|
||||
goodName: `美国(包邮${stamp})T恤-TS${stamp}-单面印花${stamp}`,
|
||||
logisticsLabel: `包邮${stamp}`,
|
||||
craftLabel: `单面印花${stamp}`,
|
||||
goodName: `美国(包邮)180g纯棉T恤成人款-DG${stamp}-单面印花`,
|
||||
logisticsLabel: '包邮',
|
||||
craftLabel: '单面印花',
|
||||
},
|
||||
});
|
||||
const og2 = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `tagsync-${stamp}-2`,
|
||||
goodName: `美国(不包邮)T恤-TS${stamp}-双面印花`,
|
||||
logisticsLabel: `不包邮`,
|
||||
craftLabel: `双面印花`,
|
||||
goodName: `美国(不包邮光板)180GT恤成人款-JS${stamp}-不打印·美西洛杉矶二仓`,
|
||||
logisticsLabel: '不包邮光板',
|
||||
craftLabel: '不打印',
|
||||
},
|
||||
});
|
||||
ids.originGood.push(og1.id, og2.id);
|
||||
@@ -81,48 +86,113 @@ describe('FamilyRecomputeService.syncFamilyTags', () => {
|
||||
where: { id: { in: [og1.id, og2.id] } },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
const good = await prisma.good.create({
|
||||
|
||||
const good1 = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og1.id,
|
||||
familyId: family.id,
|
||||
countryId: ids.country[0],
|
||||
categoryId: ids.category[0],
|
||||
goodName: `标签同步商品-${stamp}`,
|
||||
goodName: `标签同步商品1-${stamp}`,
|
||||
},
|
||||
});
|
||||
ids.good.push(good.id);
|
||||
// 预置一个人工分组标签
|
||||
await prisma.goodTag.create({ data: { goodId: good.id, tagId: tagStyle.id } });
|
||||
const good2 = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og2.id,
|
||||
familyId: family.id,
|
||||
countryId: ids.country[0],
|
||||
categoryId: ids.category[0],
|
||||
goodName: `标签同步商品2-${stamp}`,
|
||||
},
|
||||
});
|
||||
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);
|
||||
expect(r1.goodsUpdated).toBe(1);
|
||||
const names1 = (await prisma.goodTag.findMany({
|
||||
where: { goodId: good.id },
|
||||
include: { tag: true },
|
||||
})).map((t) => t.tag.tagName);
|
||||
expect(names1).toContain(`包邮${stamp}`); // og1 物流(最长前缀命中戳记标签)
|
||||
expect(names1).toContain(`单面印花${stamp}`); // og1 工艺
|
||||
expect(names1).toContain('不包邮'); // og2 物流(真实组命中)
|
||||
expect(names1).toContain('双面印'); // og2 工艺 双面印花 → 前缀命中真实标签
|
||||
expect(names1).toContain(`潮流${stamp}`); // 人工分组标签保留
|
||||
expect(r1.goodsUpdated).toBe(2);
|
||||
|
||||
const names1 = await tagNames(good1.id);
|
||||
expect(names1).toContain('单面印花');
|
||||
expect(names1).toContain('烫画');
|
||||
expect(names1).toContain('包邮');
|
||||
expect(names1).toContain(`潮流${stamp}`);
|
||||
expect(names1).not.toContain('单面印');
|
||||
expect(names1).not.toContain('双面印花');
|
||||
expect(names1).not.toContain('不包邮');
|
||||
|
||||
// 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);
|
||||
expect(r2.goodsUpdated).toBe(0);
|
||||
|
||||
// og1 工艺改掉 → 其派生的 单面印花stamped 消失(og2 工艺是 双面印花,不命中它),人工标签保留
|
||||
// 链接名称变化 → good1 标签跟随新名称
|
||||
await prisma.originGood.update({
|
||||
where: { id: og1.id },
|
||||
data: { craftLabel: `双面印花` },
|
||||
data: { goodName: `美国(不包邮)180g纯棉T恤成人款-DG${stamp}-双面印花` },
|
||||
});
|
||||
await service.syncFamilyTags(family.id);
|
||||
const names3 = (await prisma.goodTag.findMany({
|
||||
where: { goodId: good.id },
|
||||
include: { tag: true },
|
||||
})).map((t) => t.tag.tagName);
|
||||
expect(names3).not.toContain(`单面印花${stamp}`);
|
||||
expect(names3).toContain(`包邮${stamp}`);
|
||||
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: '物流渠道' } } },
|
||||
});
|
||||
const ogCustom = await prisma.originGood.create({
|
||||
data: {
|
||||
source: 'CUSTOM',
|
||||
sdsGoodId: `tagsync-custom-${stamp}`,
|
||||
goodName: `自定义包邮T恤${stamp}`,
|
||||
},
|
||||
});
|
||||
ids.originGood.push(ogCustom.id);
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
|
||||
});
|
||||
ids.family.push(family.id);
|
||||
await prisma.originGood.update({
|
||||
where: { id: ogCustom.id },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: ogCustom.id,
|
||||
familyId: family.id,
|
||||
countryId: ids.country[0],
|
||||
categoryId: ids.category[0],
|
||||
goodName: `自定义标签商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
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('烫画');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,16 +135,31 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
||||
|
||||
**族派生标签(物流/工艺/印刷位置标签自动化)**:
|
||||
**族派生标签(按链接名称自动解析,2026-08 规则改版)**:
|
||||
|
||||
- 标签组名含「物流」「工艺」「位置」的组视为**自动组**;有族商品的这些标签由族成员的
|
||||
`logisticsLabel/craftLabel` **自动生成**(前缀匹配取最长命中:单面印花→单面印、
|
||||
不包邮光板→不包邮),每次族重算/成员变更/商品创建更新时同步到族下所有商品;
|
||||
- 表单中自动组不再出现在标签下拉里(只读展示"族自动:…"),后端也会剔除手动传入的
|
||||
自动组标签;无族商品(如独立自定义商品)仍可手动打标签;
|
||||
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),因此不再按
|
||||
族并集派生,而是按每条链接自身名称解析后写入该链接对应的商品;
|
||||
- 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端
|
||||
`utils/origin-name.ts#deriveLinkTagNames` 同构):
|
||||
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(新组「印花数量」);
|
||||
- **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」);
|
||||
都不含 → 默认 `烫画`;
|
||||
- **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中);
|
||||
- 组名匹配「物流/工艺/位置/印花数量」的组视为**自动组**:每次族重算/成员变更/商品创建更新时
|
||||
同步;缺失的组与标签自动补建;旧的自动组标签(如「印刷位置」的单面印/双面印)会被剔除;
|
||||
- 后台商品表单**不再提供标签手输框**:编辑弹窗展示只读的自动标签胶囊,配置弹窗提示
|
||||
「保存后由系统按链接名称自动解析」;后端同样剔除手动传入的自动组标签;
|
||||
- **人工调节接口保留**:`设为主链接` / `移除出族` / 搜索添加成员(`updateMembers`)、
|
||||
价格改价(`/product-families/*/overrides`)——自动组织不对时可手动调整;
|
||||
- 其他分组(如风格类)不受影响,保持人工管理;
|
||||
- 实测(DG015,10 链接):商品自动获得 `包邮、不包邮、双面印、直喷、单面印`,
|
||||
官网物流/工艺筛选直接命中合并后的完整链接集合。
|
||||
- 实测:`美国(包邮)…-DG001-单面印花` → `包邮 / 烫画 / 单面印花`;
|
||||
`美国(不包邮光板)…-JSA002-不打印` → `不包邮 / 不打印 / 光板`;
|
||||
`美国(不包邮)…-DG501-双面印花` → `不包邮 / 烫画 / 双面印花`。
|
||||
|
||||
**链接名称展示解析**:后台所有链接名展示位(左右树、族成员列表、配置/编辑弹窗、搜索候选)
|
||||
统一解析为「品名 型号」(如 `美国(包邮)180g纯棉T恤成人款-DG001-单面印花` →
|
||||
`180g纯棉T恤成人款 DG001`),悬停 tooltip 保留原始全名;搜索同时匹配原始名与解析名
|
||||
(`utils/origin-name.ts#cleanLinkName`)。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ apps/api/
|
||||
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
|
||||
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
|
||||
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息)
|
||||
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算
|
||||
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules)
|
||||
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
|
||||
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
|
||||
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
|
||||
|
||||
Reference in New Issue
Block a user