feat(product-family): auto-derive logistics/craft tags from family members
This commit is contained in:
@@ -406,7 +406,7 @@ function collectSiblings(ogNode: any): any[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openConfigModal(og: any, dropTarget: any) {
|
function openConfigModal(og: any, dropTarget: any) {
|
||||||
configOG.value = og
|
configOG.value = { ...og, familyId: og.familyId ?? findOgNodeById(og.rawId)?.familyId ?? null }
|
||||||
configDropTarget.value = dropTarget
|
configDropTarget.value = dropTarget
|
||||||
const ogNode = findOgNodeById(og.rawId)
|
const ogNode = findOgNodeById(og.rawId)
|
||||||
const siblings = ogNode ? collectSiblings(ogNode) : []
|
const siblings = ogNode ? collectSiblings(ogNode) : []
|
||||||
@@ -729,7 +729,11 @@ async function openEdit(g: Good) {
|
|||||||
countryId: g.countryId,
|
countryId: g.countryId,
|
||||||
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
|
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
|
||||||
categoryId: g.categoryId,
|
categoryId: g.categoryId,
|
||||||
tagIds: (g.tags || []).map(t => t.id),
|
// 有族时:自动组(物流/工艺/位置)标签为派生数据,不进入可编辑选择
|
||||||
|
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 || '',
|
positionId: g.positionId || '',
|
||||||
}
|
}
|
||||||
initEditFamily((g as any).originGood?.family ?? null)
|
initEditFamily((g as any).originGood?.family ?? null)
|
||||||
@@ -1289,6 +1293,29 @@ const groupedTagOptions = computed(() => {
|
|||||||
return groups
|
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 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))
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
function toggleTagInSelection(tagId: string): void {
|
function toggleTagInSelection(tagId: string): void {
|
||||||
const idx = selectedTagIds.value.indexOf(tagId)
|
const idx = selectedTagIds.value.indexOf(tagId)
|
||||||
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
||||||
@@ -1922,12 +1949,15 @@ onMounted(() => loadAll())
|
|||||||
<el-form-item label="标签">
|
<el-form-item label="标签">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||||
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
<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 v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
</el-option-group>
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="configHasFamily" class="derived-tags-note">
|
||||||
|
物流 / 工艺 / 印刷位置标签由族成员自动生成,无需手动选择
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -2049,12 +2079,19 @@ onMounted(() => loadAll())
|
|||||||
<el-form-item label="标签">
|
<el-form-item label="标签">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
<el-select v-model="editForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
<el-select v-model="editForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||||
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
<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 v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
</el-option-group>
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</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>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="editIsCustom" label="基础价格">
|
<el-form-item v-if="editIsCustom" label="基础价格">
|
||||||
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
|
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
|
||||||
@@ -2491,6 +2528,10 @@ onMounted(() => loadAll())
|
|||||||
}
|
}
|
||||||
.edit-family-code { margin-left: auto; color: var(--el-color-primary); }
|
.edit-family-code { margin-left: auto; color: var(--el-color-primary); }
|
||||||
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
|
.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-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.derived-tag { pointer-events: none; }
|
||||||
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||||
.edit-merged-item {
|
.edit-merged-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface GoodRelations {
|
|||||||
}>;
|
}>;
|
||||||
_count?: { variants: number };
|
_count?: { variants: number };
|
||||||
} | null;
|
} | null;
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: bigint | null } }[];
|
||||||
mergedOriginGoods?: Array<{
|
mergedOriginGoods?: Array<{
|
||||||
originGood: {
|
originGood: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -175,6 +175,7 @@ export class GoodDto {
|
|||||||
tagName: gt.tag.tagName,
|
tagName: gt.tag.tagName,
|
||||||
tagColor: gt.tag.tagColor,
|
tagColor: gt.tag.tagColor,
|
||||||
tagFontColor: gt.tag.tagFontColor,
|
tagFontColor: gt.tag.tagFontColor,
|
||||||
|
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
|
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export class GoodsService {
|
|||||||
where: { id: BigInt(dto.originGoodId) },
|
where: { id: BigInt(dto.originGoodId) },
|
||||||
select: { familyId: true },
|
select: { familyId: true },
|
||||||
});
|
});
|
||||||
|
const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null);
|
||||||
const created = await tx.good.create({
|
const created = await tx.good.create({
|
||||||
data: {
|
data: {
|
||||||
goodName: dto.goodName,
|
goodName: dto.goodName,
|
||||||
@@ -140,9 +141,9 @@ export class GoodsService {
|
|||||||
goodPriority: dto.goodPriority ?? 0,
|
goodPriority: dto.goodPriority ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
if (tagIds.length > 0) {
|
||||||
await tx.goodTag.createMany({
|
await tx.goodTag.createMany({
|
||||||
data: dto.tagIds.map((tagId) => ({
|
data: tagIds.map((tagId) => ({
|
||||||
goodId: created.id,
|
goodId: created.id,
|
||||||
tagId: BigInt(tagId),
|
tagId: BigInt(tagId),
|
||||||
})),
|
})),
|
||||||
@@ -170,6 +171,10 @@ export class GoodsService {
|
|||||||
mergedOriginGoods: result.mergedOriginGoods,
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (result.originGood?.family?.familyId) {
|
||||||
|
// 建商品后立即同步族派生标签(物流/工艺/位置组)
|
||||||
|
await this.familyRecompute.syncFamilyTags(BigInt(result.originGood.family.familyId));
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -353,12 +358,30 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有族商品的自动组(物流/工艺/位置)标签为族派生数据,不接受手动写入
|
||||||
|
let familyIdForTags: bigint | null;
|
||||||
|
if (dto.familyId !== undefined) {
|
||||||
|
familyIdForTags = dto.familyId === null ? null : BigInt(dto.familyId);
|
||||||
|
} else {
|
||||||
|
familyIdForTags =
|
||||||
|
(
|
||||||
|
await this.prisma.good.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { familyId: true },
|
||||||
|
})
|
||||||
|
)?.familyId ?? null;
|
||||||
|
}
|
||||||
|
const updateTagIds =
|
||||||
|
dto.tagIds !== undefined
|
||||||
|
? await this.stripAutoGroupTags(dto.tagIds, familyIdForTags)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
if (dto.tagIds !== undefined) {
|
if (updateTagIds !== undefined) {
|
||||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||||
if (dto.tagIds.length > 0) {
|
if (updateTagIds.length > 0) {
|
||||||
await tx.goodTag.createMany({
|
await tx.goodTag.createMany({
|
||||||
data: dto.tagIds.map((tagId) => ({
|
data: updateTagIds.map((tagId) => ({
|
||||||
goodId: id,
|
goodId: id,
|
||||||
tagId: BigInt(tagId),
|
tagId: BigInt(tagId),
|
||||||
})),
|
})),
|
||||||
@@ -391,6 +414,10 @@ export class GoodsService {
|
|||||||
mergedOriginGoods: updated.mergedOriginGoods,
|
mergedOriginGoods: updated.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (familyIdForTags) {
|
||||||
|
// 更新后重同步族派生标签(人工编辑不会破坏派生集合)
|
||||||
|
await this.familyRecompute.syncFamilyTags(familyIdForTags);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -592,8 +619,37 @@ export class GoodsService {
|
|||||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureTag(id: number) {
|
/** 有族商品:剔除自动组(物流/工艺/位置)标签——它们由族同步管理 */
|
||||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
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 },
|
||||||
|
});
|
||||||
|
if (!autoGroups.length) return tagIds;
|
||||||
|
const autoIds = new Set(autoGroups.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 },
|
||||||
|
});
|
||||||
|
const blocked = new Set(
|
||||||
|
tags
|
||||||
|
.filter((t) => t.tagGroupId && autoIds.has(t.tagGroupId.toString()))
|
||||||
|
.map((t) => t.id.toString()),
|
||||||
|
);
|
||||||
|
return blocked.size ? tagIds.filter((id) => !blocked.has(String(id))) : tagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureTag(id: number) { const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,5 +289,94 @@ export class FamilyRecomputeService {
|
|||||||
data: { stale: true },
|
data: { stale: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 派生标签同步(无论是否锁定:标签是派生数据而非人工策展)
|
||||||
|
await this.syncFamilyTags(family.id, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族 → 商品标签同步:物流/工艺/印刷位置类标签组(组名含「物流」「工艺」或「位置」,
|
||||||
|
* 与官网筛选维度一致)下的标签由族成员的 logisticsLabel/craftLabel 派生;
|
||||||
|
* 标签名与链接标签存在词尾差异(单面印花 ↔ 单面印),采用前缀匹配并取最长命中。
|
||||||
|
* 其他分组保持人工管理。仅更新发生变化的商品,幂等。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const goods = await this.prisma.good.findMany({
|
||||||
|
where: { familyId },
|
||||||
|
include: { goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 target = [...new Set([...keep, ...derivedIds])].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 })),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
goodsUpdated += 1;
|
||||||
|
}
|
||||||
|
return { goodsUpdated };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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', () => {
|
||||||
|
let service: FamilyRecomputeService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const stamp = Date.now();
|
||||||
|
const ids = {
|
||||||
|
originGood: [] as bigint[],
|
||||||
|
family: [] as bigint[],
|
||||||
|
good: [] as bigint[],
|
||||||
|
country: [] as bigint[],
|
||||||
|
category: [] as bigint[],
|
||||||
|
group: [] as bigint[],
|
||||||
|
otherGroup: [] as bigint[],
|
||||||
|
tag: [] as bigint[],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [FamilyRecomputeService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(FamilyRecomputeService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.goodTag.deleteMany({ where: { tagId: { in: ids.tag } } });
|
||||||
|
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.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 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const og1 = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
sdsGoodId: `tagsync-${stamp}-1`,
|
||||||
|
goodName: `美国(包邮${stamp})T恤-TS${stamp}-单面印花${stamp}`,
|
||||||
|
logisticsLabel: `包邮${stamp}`,
|
||||||
|
craftLabel: `单面印花${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const og2 = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
sdsGoodId: `tagsync-${stamp}-2`,
|
||||||
|
goodName: `美国(不包邮)T恤-TS${stamp}-双面印花`,
|
||||||
|
logisticsLabel: `不包邮`,
|
||||||
|
craftLabel: `双面印花`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.originGood.push(og1.id, og2.id);
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `标签同步族-${stamp}`, primaryOriginGoodId: og1.id },
|
||||||
|
});
|
||||||
|
ids.family.push(family.id);
|
||||||
|
await prisma.originGood.updateMany({
|
||||||
|
where: { id: { in: [og1.id, og2.id] } },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og1.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId: ids.country[0],
|
||||||
|
categoryId: ids.category[0],
|
||||||
|
goodName: `标签同步商品-${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.good.push(good.id);
|
||||||
|
// 预置一个人工分组标签
|
||||||
|
await prisma.goodTag.create({ data: { goodId: good.id, tagId: tagStyle.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}`); // 人工分组标签保留
|
||||||
|
|
||||||
|
// 幂等:无变化不写
|
||||||
|
const r2 = await service.syncFamilyTags(family.id);
|
||||||
|
expect(r2.goodsUpdated).toBe(0);
|
||||||
|
|
||||||
|
// og1 工艺改掉 → 其派生的 单面印花stamped 消失(og2 工艺是 双面印花,不命中它),人工标签保留
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og1.id },
|
||||||
|
data: { craftLabel: `双面印花` },
|
||||||
|
});
|
||||||
|
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}`);
|
||||||
|
expect(names3).toContain(`潮流${stamp}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -129,12 +129,24 @@ pnpm --filter @inkreach/api backfill:product-families
|
|||||||
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
|
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
|
||||||
|
|
||||||
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
||||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动建族);
|
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);
|
||||||
- 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」:
|
- 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」:
|
||||||
显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员——
|
显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员——
|
||||||
操作直接作用于族(并集与价格矩阵随重算更新);
|
操作直接作用于族(并集与价格矩阵随重算更新);
|
||||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
||||||
|
|
||||||
|
**族派生标签(物流/工艺/印刷位置标签自动化)**:
|
||||||
|
|
||||||
|
- 标签组名含「物流」「工艺」「位置」的组视为**自动组**;有族商品的这些标签由族成员的
|
||||||
|
`logisticsLabel/craftLabel` **自动生成**(前缀匹配取最长命中:单面印花→单面印、
|
||||||
|
不包邮光板→不包邮),每次族重算/成员变更/商品创建更新时同步到族下所有商品;
|
||||||
|
- 表单中自动组不再出现在标签下拉里(只读展示"族自动:…"),后端也会剔除手动传入的
|
||||||
|
自动组标签;无族商品(如独立自定义商品)仍可手动打标签;
|
||||||
|
- 其他分组(如风格类)不受影响,保持人工管理;
|
||||||
|
- 实测(DG015,10 链接):商品自动获得 `包邮、不包邮、双面印、直喷、单面印`,
|
||||||
|
官网物流/工艺筛选直接命中合并后的完整链接集合。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|||||||
Reference in New Issue
Block a user