From 08201f18a4a04c366485b579a2b86171ffc4b38a Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 16:00:46 +0800 Subject: [PATCH 1/2] feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment --- .../migration.sql | 25 +++ apps/api/prisma/schema.prisma | 21 ++ .../dto/update-origin-good-tags.dto.ts | 18 ++ .../origin-goods/origin-goods.controller.ts | 21 +- .../src/origin-goods/origin-goods.module.ts | 2 + .../origin-goods/origin-goods.service.spec.ts | 3 +- .../src/origin-goods/origin-goods.service.ts | 110 +++++++++- .../family-recompute.service.ts | 188 ++++++++++++++++-- .../product-families/family-tag-sync.spec.ts | 161 +++++++++------ .../product-families.service.ts | 12 ++ apps/api/src/public/public.service.spec.ts | 3 +- 11 files changed, 477 insertions(+), 87 deletions(-) create mode 100644 apps/api/prisma/migrations/20260828074150_add_origin_good_tags/migration.sql create mode 100644 apps/api/src/origin-goods/dto/update-origin-good-tags.dto.ts diff --git a/apps/api/prisma/migrations/20260828074150_add_origin_good_tags/migration.sql b/apps/api/prisma/migrations/20260828074150_add_origin_good_tags/migration.sql new file mode 100644 index 0000000..2149f1d --- /dev/null +++ b/apps/api/prisma/migrations/20260828074150_add_origin_good_tags/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 270aa76..6d3036d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/origin-goods/dto/update-origin-good-tags.dto.ts b/apps/api/src/origin-goods/dto/update-origin-good-tags.dto.ts new file mode 100644 index 0000000..d2b22c9 --- /dev/null +++ b/apps/api/src/origin-goods/dto/update-origin-good-tags.dto.ts @@ -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[]; +} diff --git a/apps/api/src/origin-goods/origin-goods.controller.ts b/apps/api/src/origin-goods/origin-goods.controller.ts index 70d1a4a..1d3e35c 100644 --- a/apps/api/src/origin-goods/origin-goods.controller.ts +++ b/apps/api/src/origin-goods/origin-goods.controller.ts @@ -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)); + } } diff --git a/apps/api/src/origin-goods/origin-goods.module.ts b/apps/api/src/origin-goods/origin-goods.module.ts index 33e34b4..f0a00af 100644 --- a/apps/api/src/origin-goods/origin-goods.module.ts +++ b/apps/api/src/origin-goods/origin-goods.module.ts @@ -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], }) diff --git a/apps/api/src/origin-goods/origin-goods.service.spec.ts b/apps/api/src/origin-goods/origin-goods.service.spec.ts index 1adf7a4..fe9bef3 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -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); diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index ed0fea0..11887bd 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -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 { + 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 { + 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 { + 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 { const { page, pageSize, keyword } = query; diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index 25bb1a2..6c89464 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -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(); + 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 { + 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 { + 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, + ): 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,天然去重) */ diff --git a/apps/api/src/product-families/family-tag-sync.spec.ts b/apps/api/src/product-families/family-tag-sync.spec.ts index 4643c52..a453e31 100644 --- a/apps/api/src/product-families/family-tag-sync.spec.ts +++ b/apps/api/src/product-families/family-tag-sync.spec.ts @@ -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 { - const rows = await prisma.goodTag.findMany({ - where: { goodId }, - include: { tag: true }, - }); + async function goodTagNames(goodId: bigint): Promise { + 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 { + 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(['包邮']); }); }); diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index f624e06..2b1f445 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -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, diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index e755447..5099ed9 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -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); From 6f22a6fd1f91272e87e718886d8ae9babd7824c7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 16:00:46 +0800 Subject: [PATCH 2/2] feat(admin): expandable family members with per-link tag config + SKU pricing dims columns --- apps/admin/src/api/origin-goods.ts | 16 +- apps/admin/src/types/index.ts | 18 ++ apps/admin/src/utils/origin-name.spec.ts | 21 ++ apps/admin/src/utils/origin-name.ts | 29 +++ apps/admin/src/views/goods/GoodsView.vue | 316 +++++++++++++++-------- docs/references/product-center.md | 31 ++- docs/references/structs.md | 3 +- 7 files changed, 312 insertions(+), 122 deletions(-) diff --git a/apps/admin/src/api/origin-goods.ts b/apps/admin/src/api/origin-goods.ts index dd95685..dd1be73 100644 --- a/apps/admin/src/api/origin-goods.ts +++ b/apps/admin/src/api/origin-goods.ts @@ -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>('/origin-goods', { params }) }, + + getTags: (id: string) => { + return request.get(`/origin-goods/${id}/tags`) + }, + + // 人工接管链接标签(全量替换,自动同步不再覆盖) + updateTags: (id: string, tagIds: string[]) => { + return request.put(`/origin-goods/${id}/tags`, { tagIds: tagIds.map(Number) }) + }, + + // 恢复自动派生(清掉人工标签) + resetTags: (id: string) => { + return request.delete(`/origin-goods/${id}/tags`) + }, } diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 3585d63..3e2fffa 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -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 { diff --git a/apps/admin/src/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts index 71af732..ffaab18 100644 --- a/apps/admin/src/utils/origin-name.spec.ts +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -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 }); + }); +}); diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 39cad2a..56ca0d6 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -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 规则一致,仅用于成员行只读展示): * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画; diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 50cbdd1..8269b4a 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -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(null) const originalOriginGoodId = ref('') 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('') const editFamilyCode = ref(null) -const editFamilyMembers = ref>([]) +const editFamilyMembers = ref([]) const familyMemberCount = ref(null) const editFamilyLoaded = ref(false) const editFamilyAddKw = ref('') const editFamilyCandidates = ref>([]) +/** 展开的成员行 key(og id) */ +const expandedMemberKeys = ref>(new Set()) +/** 成员标签编辑态(key = og id) */ +const memberTagEdits = ref>({}) +const memberTagSaving = ref('') +/** 商品自身的链接定价维度(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 = {} + 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()) - -
标签无需手动选择:保存后由系统按链接名称自动解析(印花数量 / 工艺 / 物流)
-
- - - - - - - - - + @@ -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; diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 7a8fc7a..02015a3 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -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-双面印花` → `不包邮 / 烫画 / 双面印花`。 diff --git a/docs/references/structs.md b/docs/references/structs.md index ef47bb9..308eb8c 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -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 |