From eedfbb344e27821ee6c41c4f26b5479fa1783aef Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 14:57:25 +0800 Subject: [PATCH] feat(product-family): auto-derive logistics/craft tags from family members --- apps/admin/src/views/goods/GoodsView.vue | 49 ++++++- apps/api/src/goods/dto/good.dto.ts | 3 +- apps/api/src/goods/goods.service.ts | 70 +++++++++- .../family-recompute.service.ts | 89 ++++++++++++ .../product-families/family-tag-sync.spec.ts | 128 ++++++++++++++++++ docs/references/product-center.md | 14 +- 6 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/product-families/family-tag-sync.spec.ts diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 8456469..02d462d 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -406,7 +406,7 @@ function collectSiblings(ogNode: any): any[] { } function openConfigModal(og: any, dropTarget: any) { - configOG.value = og + configOG.value = { ...og, familyId: og.familyId ?? findOgNodeById(og.rawId)?.familyId ?? null } configDropTarget.value = dropTarget const ogNode = findOgNodeById(og.rawId) const siblings = ogNode ? collectSiblings(ogNode) : [] @@ -729,7 +729,11 @@ async function openEdit(g: Good) { countryId: g.countryId, cascaderCategory: findCategoryPath(allCategories.value, 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 || '', } initEditFamily((g as any).originGood?.family ?? null) @@ -1289,6 +1293,29 @@ 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 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 { const idx = selectedTagIds.value.indexOf(tagId) if (idx >= 0) selectedTagIds.value.splice(idx, 1) @@ -1922,12 +1949,15 @@ onMounted(() => loadAll()) - + { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" /> + + 物流 / 工艺 / 印刷位置标签由族成员自动生成,无需手动选择 + @@ -2049,12 +2079,19 @@ onMounted(() => loadAll()) - + { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" /> + + 族自动: + {{ t.tagName }} + + + 物流 / 工艺 / 印刷位置标签由族成员自动生成,无需手动选择 + @@ -2491,6 +2528,10 @@ onMounted(() => loadAll()) } .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; } +.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-item { display: flex; align-items: center; gap: 8px; diff --git a/apps/api/src/goods/dto/good.dto.ts b/apps/api/src/goods/dto/good.dto.ts index 7afa628..90e4682 100644 --- a/apps/api/src/goods/dto/good.dto.ts +++ b/apps/api/src/goods/dto/good.dto.ts @@ -33,7 +33,7 @@ export interface GoodRelations { }>; _count?: { variants: number }; } | 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<{ originGood: { id: bigint; @@ -175,6 +175,7 @@ export class GoodDto { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, tagFontColor: gt.tag.tagFontColor, + tagGroupId: gt.tag.tagGroupId?.toString() ?? null, })) : [], mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({ diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 19ef667..67ba945 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -128,6 +128,7 @@ export class GoodsService { where: { id: BigInt(dto.originGoodId) }, select: { familyId: true }, }); + const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null); const created = await tx.good.create({ data: { goodName: dto.goodName, @@ -140,9 +141,9 @@ export class GoodsService { goodPriority: dto.goodPriority ?? 0, }, }); - if (dto.tagIds && dto.tagIds.length > 0) { + if (tagIds.length > 0) { await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ + data: tagIds.map((tagId) => ({ goodId: created.id, tagId: BigInt(tagId), })), @@ -170,6 +171,10 @@ export class GoodsService { mergedOriginGoods: result.mergedOriginGoods, }); }); + if (result.originGood?.family?.familyId) { + // 建商品后立即同步族派生标签(物流/工艺/位置组) + await this.familyRecompute.syncFamilyTags(BigInt(result.originGood.family.familyId)); + } if ( result.originGood?.source === 'SDS' && 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) => { - if (dto.tagIds !== undefined) { + if (updateTagIds !== undefined) { await tx.goodTag.deleteMany({ where: { goodId: id } }); - if (dto.tagIds.length > 0) { + if (updateTagIds.length > 0) { await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ + data: updateTagIds.map((tagId) => ({ goodId: id, tagId: BigInt(tagId), })), @@ -391,6 +414,10 @@ export class GoodsService { mergedOriginGoods: updated.mergedOriginGoods, }); }); + if (familyIdForTags) { + // 更新后重同步族派生标签(人工编辑不会破坏派生集合) + await this.familyRecompute.syncFamilyTags(familyIdForTags); + } if ( result.originGood?.source === 'SDS' && result.originGood.sdsGoodId && @@ -592,8 +619,37 @@ export class GoodsService { 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 { + 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`); } diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index 6e334bd..b355621 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -289,5 +289,94 @@ export class FamilyRecomputeService { 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(); + 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 }; } } diff --git a/apps/api/src/product-families/family-tag-sync.spec.ts b/apps/api/src/product-families/family-tag-sync.spec.ts new file mode 100644 index 0000000..dc67adf --- /dev/null +++ b/apps/api/src/product-families/family-tag-sync.spec.ts @@ -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}`); + }); +}); diff --git a/docs/references/product-center.md b/docs/references/product-center.md index f3ba325..cef8986 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -129,12 +129,24 @@ pnpm --filter @inkreach/api backfill:product-families **后台操作入口(族替代旧主源/副源,界面保持原有布局)**: - 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」 - 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动建族); + 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族); - 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」: 显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员—— 操作直接作用于族(并集与价格矩阵随重算更新); - 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。 +**族派生标签(物流/工艺/印刷位置标签自动化)**: + +- 标签组名含「物流」「工艺」「位置」的组视为**自动组**;有族商品的这些标签由族成员的 + `logisticsLabel/craftLabel` **自动生成**(前缀匹配取最长命中:单面印花→单面印、 + 不包邮光板→不包邮),每次族重算/成员变更/商品创建更新时同步到族下所有商品; +- 表单中自动组不再出现在标签下拉里(只读展示"族自动:…"),后端也会剔除手动传入的 + 自动组标签;无族商品(如独立自定义商品)仍可手动打标签; +- 其他分组(如风格类)不受影响,保持人工管理; +- 实测(DG015,10 链接):商品自动获得 `包邮、不包邮、双面印、直喷、单面印`, + 官网物流/工艺筛选直接命中合并后的完整链接集合。 + + ## 验证