diff --git a/.gitignore b/.gitignore index b101840..1876ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ deploy/data-dump.json uploads/ .pnpm-store/ data-cleaning/ -backups/ \ No newline at end of file +backups/.zcode/ diff --git a/apps/admin/src/api/product-families.ts b/apps/admin/src/api/product-families.ts index d97f009..911723f 100644 --- a/apps/admin/src/api/product-families.ts +++ b/apps/admin/src/api/product-families.ts @@ -36,6 +36,18 @@ export const productFamiliesApi = { ) }, + /** 整理原产品库(显式人工动作):解析列回填 → 派生标签 → 自动建族 → 全量重算 */ + organize: () => { + return request.post('/product-families/organize') + }, + updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => { return request.post(`/product-families/${id}/members`, data) }, diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 898a356..b776d48 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -16,6 +16,7 @@ import { categoriesApi } from '@/api/categories' import { tagsApi } from '@/api/tags' import { tagGroupsApi } from '@/api/tag-groups' import { originGoodsApi } from '@/api/origin-goods' +import { productFamiliesApi } from '@/api/product-families' import { syncApi } from '@/api/sync' import { sameFamily } from '@/utils/family-match' import { sameOriginGroup } from '@/utils/origin-name' @@ -999,6 +1000,32 @@ function onSearch() { rightTreeRef.value?.filter?.('') } +/** 整理原产品库:解析列回填 → 派生标签 → 自动建族 → 全量重算(显式人工动作) */ +const organizing = ref(false) +async function onOrganize() { + try { + await ElMessageBox.confirm( + '将执行:回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算矩阵。可能耗时较长,继续?', + '整理原产品库', + { confirmButtonText: '整理', cancelButtonText: '取消' }, + ) + } catch { + return + } + organizing.value = true + try { + const r = await productFamiliesApi.organize() + ElMessage.success( + `整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 重算 ${r.familiesRecomputed} 族`, + ) + await loadAll() + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || '整理失败') + } finally { + organizing.value = false + } +} + onMounted(() => loadAll()) @@ -1036,6 +1063,7 @@ onMounted(() => loadAll()) 搜索
+ 整理 新增自定义商品
diff --git a/apps/api/package.json b/apps/api/package.json index 1931d82..4c8c08d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,7 +23,8 @@ "prisma:studio": "prisma studio", "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "import:product-detail": "ts-node prisma/import-product-detail.ts", - "backfill:product-families": "ts-node prisma/backfill-product-families.ts" + "backfill:product-families": "ts-node prisma/backfill-product-families.ts", + "organize": "ts-node prisma/backfill-product-families.ts" }, "dependencies": { "@nestjs/axios": "^3.0.1", diff --git a/apps/api/prisma/backfill-product-families.ts b/apps/api/prisma/backfill-product-families.ts index 8a06555..c787373 100644 --- a/apps/api/prisma/backfill-product-families.ts +++ b/apps/api/prisma/backfill-product-families.ts @@ -1,81 +1,35 @@ /** - * 产品族回填脚本(一次性 / 幂等): - * 1. 全量 OriginGood 回填四个链接名解析列; - * 2. auto-group 全量建族并挂成员(每族建立即重算); - * 3. 输出统计与不可解析清单。 + * 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口: + * 1. 回填结构化解析列(只补 NULL,不覆盖存量); + * 2. 派生链接标签(未人工接管的 SDS 链接按名称刷新,人工接管不动)+ 商品镜像; + * 3. 自动建族(auto-group); + * 4. 全量族重算(并集尺码表/包装 + 五维价格矩阵)。 * - * 运行:pnpm --filter @inkreach/api backfill:product-families - * 幂等性:重复执行时步骤 1 数据不变、步骤 2 候选为空(familyId=null 过滤)。 + * 运行:pnpm --filter @inkreach/api organize + * 后台等价入口:POST /product-families/organize(「整理」按钮)。 */ -import { Prisma } from '@prisma/client'; import { PrismaService } from '../src/prisma/prisma.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; import { ProductFamiliesService } from '../src/product-families/product-families.service'; -import { parseOriginName } from '../src/product-families/origin-name.parser'; +import { OrganizeService } from '../src/product-families/organize.service'; async function main() { const prisma = new PrismaService(); await prisma.onModuleInit(); const recompute = new FamilyRecomputeService(prisma); const families = new ProductFamiliesService(prisma, recompute); + const organize = new OrganizeService(prisma, recompute, families); - // ---- 1. 解析列回填(分批) ---- - const BATCH = 100; - let parsed = 0; - const unparsable: string[] = []; - for (;;) { - const batch = await prisma.originGood.findMany({ - orderBy: { id: 'asc' }, - take: BATCH, - skip: parsed, - select: { id: true, goodName: true }, - }); - if (batch.length === 0) break; - for (const og of batch) { - const p = parseOriginName(og.goodName); - if (!p.skuCode && !p.craftLabel) unparsable.push(`#${og.id} ${og.goodName ?? ''}`); - await prisma.originGood.update({ - where: { id: og.id }, - data: { - skuCode: p.skuCode, - logisticsLabel: p.logisticsLabel, - craftLabel: p.craftLabel, - warehouseLabel: p.warehouseLabel, - }, - }); - } - parsed += batch.length; - } - console.log(`[1/2] parsed ${parsed} origin goods (${unparsable.length} without sku/craft)`); - - // ---- 2. 自动建族(含逐族重算) ---- - const result = await families.autoGroup(true); - console.log(`[2/2] created ${result.applied} families`); - - // ---- 3. 全量族重算兜底(修复建族早于解析列回填等时序造成的空矩阵) ---- - const allFamilies = await prisma.productFamily.findMany({ select: { id: true } }); - for (const f of allFamilies) { - await recompute.recomputeFamily(f.id); - } - console.log(`[3/3] recomputed ${allFamilies.length} families`); - - // ---- 统计 ---- - const total = await prisma.productFamily.count(); - const withMatrix = await prisma.productFamily.count({ - where: { priceMatrix: { not: Prisma.DbNull } }, - }); - const members = await prisma.originGood.count({ where: { familyId: { not: null } } }); - const stale = await prisma.productFamily.count({ where: { stale: true } }); - console.log(`stats: families=${total}, materialized=${withMatrix}, members=${members}, stale=${stale}`); - if (unparsable.length) { - console.log('unparsable names:'); - for (const line of unparsable) console.log(` - ${line}`); - } - - await prisma.$disconnect(); + const result = await organize.organize(); + console.log( + `[organize] labels parsed=${result.labelsParsed} (unparsable=${result.labelsUnparsable}) | ` + + `tags links=${result.linksUpdated} goods=${result.goodsUpdated} | ` + + `families created=${result.familiesCreated} recomputed=${result.familiesRecomputed}`, + ); + await prisma.onModuleDestroy(); } -main().catch((err) => { - console.error(err); +main().catch((error) => { + console.error(error); process.exit(1); }); diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index 475cee3..839c587 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -274,6 +274,44 @@ describe('GoodsService', () => { expect(after.total).toBe(before.total); }); + describe('good name normalization', () => { + it('create/update 时把结构完整的原始链接名规范化为 品名+型号', async () => { + const created = await service.create({ + goodName: '德国(不包邮)230g水洗T恤-DETM002-双面印花', + originGoodId: Number(originGoodIds[0]), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe('230g水洗T恤 DETM002'); + const updated = await service.update(BigInt(created.id), { + goodName: '加拿大(不包邮)180g纯棉T恤-CATM001-单面印花', + }); + expect(updated.goodName).toBe('180g纯棉T恤 CATM001'); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('非链接结构的名称原样保留(已解析名/自定义名)', async () => { + const created = await service.create({ + goodName: '230g水洗T恤 DETM002', + originGoodId: Number(originGoodIds[0]), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe('230g水洗T恤 DETM002'); + const updated = await service.update(BigInt(created.id), { + goodName: '自定义商品 ABC', + }); + expect(updated.goodName).toBe('自定义商品 ABC'); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + }); + describe('merged origin goods', () => { it('creates a good with merged origin goods and reads them back', async () => { const created = await service.create({ diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 9abeed1..da98884 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -1,754 +1,774 @@ -import { Prisma } from '@prisma/client'; -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { CreateGoodDto } from './dto/create-good.dto'; -import { UpdateGoodDto } from './dto/update-good.dto'; -import { QueryGoodDto } from './dto/query-good.dto'; -import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; -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, - CustomGoodDetailDto, - CustomGoodVariantDto, - UpdateCustomGoodContentDto, -} from './dto/custom-good.dto'; - -const GOOD_INCLUDE = { - country: true, - category: true, - tag: true, - position: true, - originGood: { - include: { - detail: true, - variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, - _count: { select: { variants: true } }, - family: { select: { id: true, familyCode: true, familyName: true, stale: true } }, - }, - }, - goodTags: { include: { tag: true } }, - mergedOriginGoods: { - orderBy: { createdAt: 'asc' }, - include: { - originGood: { - include: { - detail: true, - variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, - _count: { select: { variants: true } }, - }, - }, - }, - }, -} satisfies Prisma.GoodInclude; - -@Injectable() -export class GoodsService { - constructor( - private readonly prisma: PrismaService, - private readonly syncService: SyncService, - private readonly familyRecompute: FamilyRecomputeService, - ) {} - - async findAll(query: QueryGoodDto): Promise { - const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query; - const where: Prisma.GoodWhereInput = {}; - if (countryId !== undefined) where.countryId = BigInt(countryId); - if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } }; - if (positionId !== undefined) where.positionId = BigInt(positionId); - if (keyword) { - where.goodName = { contains: keyword, mode: 'insensitive' }; - } - if (categoryId !== undefined) { - const ids = await this.collectCategoryDescendants(BigInt(categoryId)); - where.categoryId = { in: ids }; - } - - const [total, rows] = await this.prisma.$transaction([ - this.prisma.good.count({ where }), - this.prisma.good.findMany({ - where, - include: GOOD_INCLUDE, - orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }], - skip: (page - 1) * pageSize, - take: pageSize, - }), - ]); - - return { - items: rows.map((g) => GoodDto.from(g, { - country: g.country, - category: g.category, - tag: g.tag, - position: g.position, - originGood: g.originGood, - goodTags: g.goodTags, - mergedOriginGoods: g.mergedOriginGoods, - })), - total, - page, - pageSize, - }; - } - - async findOne(id: bigint): Promise { - const good = await this.prisma.good.findUnique({ - where: { id }, - include: GOOD_INCLUDE, - }); - if (!good) throw new NotFoundException(`Good ${id} not found`); - return GoodDetailDto.fromGood(good, { - country: good.country, - category: good.category, - tag: good.tag, - position: good.position, - originGood: good.originGood, - goodTags: good.goodTags, - mergedOriginGoods: good.mergedOriginGoods, - }); - } - - async create(dto: CreateGoodDto): Promise { - await this.ensureReferences(dto); - const mergedIds = this.dedupeMergedIds( - BigInt(dto.originGoodId), - dto.mergedOriginGoodIds, - ); - await this.ensureMergedOriginGoods(mergedIds); - const result = await this.prisma.$transaction(async (tx) => { - // Good 的族是派生数据:主链接所属族 - const primary = await tx.originGood.findUnique({ - 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, - goodImage: dto.goodImage, - originGoodId: BigInt(dto.originGoodId), - familyId: primary?.familyId ?? null, - countryId: BigInt(dto.countryId), - categoryId: BigInt(dto.categoryId), - positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), - goodPriority: dto.goodPriority ?? 0, - }, - }); - if (tagIds.length > 0) { - await tx.goodTag.createMany({ - data: tagIds.map((tagId) => ({ - goodId: created.id, - tagId: BigInt(tagId), - })), - }); - } - if (mergedIds.length > 0) { - await tx.goodOriginGood.createMany({ - data: mergedIds.map((originGoodId) => ({ - goodId: created.id, - originGoodId, - })), - }); - } - const result = await tx.good.findUniqueOrThrow({ - where: { id: created.id }, - include: GOOD_INCLUDE, - }); - return GoodDto.from(result, { - country: result.country, - category: result.category, - tag: result.tag, - position: result.position, - originGood: result.originGood, - goodTags: result.goodTags, - 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 && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; - } - - async createCustom(dto: CreateCustomGoodDto): Promise { - await this.ensureCountry(dto.countryId); - await this.ensureCategory(dto.categoryId); - if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); - for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); - let family: { id: bigint } | null = null; - if (dto.familyId !== undefined) { - family = await this.prisma.productFamily.findUnique({ - where: { id: BigInt(dto.familyId) }, - select: { id: true }, - }); - if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`); - } - - const goodId = await this.prisma.$transaction(async (tx) => { - const originGood = await tx.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: `custom-${randomUUID()}`, - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPrice: this.decimal(dto.goodPrice), - ...(family ? { familyId: family.id } : {}), - ...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined - ? { - logisticsLabel: dto.logisticsLabel ?? null, - craftLabel: dto.craftLabel ?? null, - skuCode: dto.skuCode ?? null, - warehouseLabel: dto.warehouseLabel ?? null, - } - : {}), - detail: { - create: this.customDetailData( - dto.detail ?? {}, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, - }, - }, - }); - if (dto.variants?.length) { - await this.replaceCustomVariants(tx, originGood.id, dto.variants); - } - const good = await tx.good.create({ - data: { - originGoodId: originGood.id, - familyId: family?.id ?? null, - countryId: BigInt(dto.countryId), - categoryId: BigInt(dto.categoryId), - positionId: - dto.positionId === undefined ? null : BigInt(dto.positionId), - goodName: dto.goodName, - goodImage: dto.goodImage ?? null, - goodPriority: dto.goodPriority ?? 0, - }, - }); - if (dto.tagIds?.length) { - await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ - goodId: good.id, - tagId: BigInt(tagId), - })), - }); - } - return good.id; - }); - if (family) this.familyRecompute.enqueue(family.id); - return this.findOne(goodId); - } - - async updateCustomContent( - id: bigint, - dto: UpdateCustomGoodContentDto, - ): Promise { - const existing = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!existing) throw new NotFoundException(`Good ${id} not found`); - if (existing.originGood.source !== 'CUSTOM') { - throw new BadRequestException('SDS 映射商品的上游信息不可修改'); - } - - await this.prisma.$transaction(async (tx) => { - await tx.originGood.update({ - where: { id: existing.originGoodId }, - data: { - goodName: dto.goodName, - goodImage: dto.goodImage, - goodPrice: - dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), - }, - }); - if (dto.detail !== undefined) { - await tx.originGoodDetail.upsert({ - where: { originGoodId: existing.originGoodId }, - create: { - originGoodId: existing.originGoodId, - ...(this.customDetailData( - dto.detail, - ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), - }, - update: this.customDetailData(dto.detail, true), - }); - } - if (dto.variants !== undefined) { - await this.replaceCustomVariants( - tx, - existing.originGoodId, - dto.variants, - ); - } - const goodData: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) goodData.goodName = dto.goodName; - if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; - if (Object.keys(goodData).length) { - await tx.good.update({ where: { id }, data: goodData }); - } - }); - return this.findOne(id); - } - - async update(id: bigint, dto: UpdateGoodDto): Promise { - await this.findOne(id); - let mergedIds: bigint[] | undefined; - if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) { - const current = await this.prisma.good.findUniqueOrThrow({ - where: { id }, - select: { originGoodId: true }, - }); - const primaryId = - dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId; - mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds); - await this.ensureMergedOriginGoods(mergedIds); - } - const data: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) data.goodName = dto.goodName; - if (dto.originGoodId !== undefined) { - await this.ensureOriginGood(dto.originGoodId); - data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; - } - if (dto.countryId !== undefined) { - await this.ensureCountry(dto.countryId); - data.country = { connect: { id: BigInt(dto.countryId) } }; - } - if (dto.categoryId !== undefined) { - await this.ensureCategory(dto.categoryId); - data.category = { connect: { id: BigInt(dto.categoryId) } }; - } - if (dto.positionId !== undefined) { - data.position = - dto.positionId === null - ? { disconnect: true } - : { connect: { id: BigInt(dto.positionId) } }; - } - if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority; - if (dto.goodImage !== undefined) data.goodImage = dto.goodImage; - if (dto.familyId !== undefined) { - if (dto.familyId !== null) { - const family = await this.prisma.productFamily.findUnique({ - where: { id: BigInt(dto.familyId) }, - select: { id: true }, - }); - if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`); - data.family = { connect: { id: family.id } }; - } else { - data.family = { disconnect: true }; - } - } - if (dto.tagIds !== undefined) { - for (const tagId of dto.tagIds) { - await this.ensureTag(tagId); - } - } - - // 有族商品的自动组(物流/工艺/位置)标签为族派生数据,不接受手动写入 - 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 (updateTagIds !== undefined) { - await tx.goodTag.deleteMany({ where: { goodId: id } }); - if (updateTagIds.length > 0) { - await tx.goodTag.createMany({ - data: updateTagIds.map((tagId) => ({ - goodId: id, - tagId: BigInt(tagId), - })), - }); - } - } - if (mergedIds !== undefined) { - await tx.goodOriginGood.deleteMany({ where: { goodId: id } }); - if (mergedIds.length > 0) { - await tx.goodOriginGood.createMany({ - data: mergedIds.map((originGoodId) => ({ - goodId: id, - originGoodId, - })), - }); - } - } - const updated = await tx.good.update({ - where: { id }, - data, - include: GOOD_INCLUDE, - }); - return GoodDto.from(updated, { - country: updated.country, - category: updated.category, - tag: updated.tag, - position: updated.position, - originGood: updated.originGood, - goodTags: updated.goodTags, - mergedOriginGoods: updated.mergedOriginGoods, - }); - }); - if (familyIdForTags) { - // 更新后重同步族派生标签(人工编辑不会破坏派生集合) - await this.familyRecompute.syncFamilyTags(familyIdForTags); - } - if ( - result.originGood?.source === 'SDS' && - result.originGood.sdsGoodId && - !result.originGood.hasDetail - ) { - this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); - } - return result; - } - - async remove(id: bigint): Promise<{ id: string }> { - const good = await this.prisma.good.findUnique({ - where: { id }, - include: { originGood: true }, - }); - if (!good) throw new NotFoundException(`Good ${id} not found`); - await this.prisma.$transaction(async (tx) => { - await tx.good.delete({ where: { id } }); - if (good.originGood.source === 'CUSTOM') { - const remaining = await tx.good.count({ - where: { originGoodId: good.originGoodId }, - }); - if (remaining === 0) { - await tx.originGood.delete({ where: { id: good.originGoodId } }); - } - } - }); - return { id: id.toString() }; - } - - /** - * Updates priorities in a single transaction; either all rows update - * or none do. - */ - async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> { - const result = await this.prisma.$transaction(async (tx) => { - for (const item of dto.items) { - await tx.good.update({ - where: { id: BigInt(item.id) }, - data: { goodPriority: item.priority }, - }); - } - return { count: dto.items.length }; - }); - return result; - } - - /** - * Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId - * and a default priority that may be overridden per item. - */ - async batchCreate(dto: BatchCreateGoodDto): Promise { - const defaultPriority = dto.defaultPriority ?? 0; - if (dto.tagIds && dto.tagIds.length > 0) { - for (const tagId of dto.tagIds) { - await this.ensureTag(tagId); - } - } - const result = await this.prisma.$transaction(async (tx) => { - const created: GoodDto[] = []; - for (const item of dto.items) { - const og = await tx.originGood.findUnique({ - where: { id: BigInt(item.originGoodId) }, - }); - if (!og) { - throw new BadRequestException( - `Origin good ${item.originGoodId} not found`, - ); - } - const row = await tx.good.create({ - data: { - goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, - goodImage: og.goodImage, - originGoodId: og.id, - familyId: og.familyId, - countryId: BigInt(dto.countryId), - categoryId: BigInt(dto.categoryId), - positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), - goodPriority: item.priority ?? defaultPriority, - }, - }); - if (dto.tagIds && dto.tagIds.length > 0) { - await tx.goodTag.createMany({ - data: dto.tagIds.map((tagId) => ({ - goodId: row.id, - tagId: BigInt(tagId), - })), - }); - } - const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds); - if (itemMerged.length > 0) { - const existRows = await tx.originGood.findMany({ - where: { id: { in: itemMerged } }, - select: { id: true }, - }); - if (existRows.length !== itemMerged.length) { - const found = new Set(existRows.map((r) => r.id.toString())); - const missing = itemMerged.find((mid) => !found.has(mid.toString())); - throw new BadRequestException(`Origin good ${missing} not found`); - } - await tx.goodOriginGood.createMany({ - data: itemMerged.map((originGoodId) => ({ - goodId: row.id, - originGoodId, - })), - }); - } - const result = await tx.good.findUniqueOrThrow({ - where: { id: row.id }, - include: GOOD_INCLUDE, - }); - created.push(GoodDto.from(result, { - country: result.country, - category: result.category, - tag: result.tag, - position: result.position, - originGood: result.originGood, - goodTags: result.goodTags, - mergedOriginGoods: result.mergedOriginGoods, - })); - } - return created; - }); - for (const goodId of new Set( - result - .filter( - (item) => - item.originGood?.source === 'SDS' && - !item.originGood.hasDetail, - ) - .map((item) => item.originGood?.sdsGoodId) - .filter((id): id is string => Boolean(id)), - )) { - this.syncService.queueProductDetailSync(goodId); - } - return result; - } - - /** - * Walk the category tree and return the requested id + all of its - * descendants. We use a level-by-level BFS to keep the queries small - * for the typical tree sizes we expect. - */ - private async collectCategoryDescendants(rootId: bigint): Promise { - const ids: bigint[] = [rootId]; - let frontier: bigint[] = [rootId]; - while (frontier.length > 0) { - const children = await this.prisma.category.findMany({ - where: { parentCategoryId: { in: frontier } }, - select: { id: true }, - }); - if (children.length === 0) break; - const childIds = children.map((c) => c.id); - ids.push(...childIds); - frontier = childIds; - } - return ids; - } - - private async ensureOriginGood(id: number) { - const og = await this.prisma.originGood.findUnique({ - where: { id: BigInt(id) }, - }); - if (!og) throw new BadRequestException(`Origin good ${id} not found`); - } - - /** Dedupe merged ids and reject any that equals the primary source. */ - private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] { - if (!ids || ids.length === 0) return []; - const unique = [...new Set(ids.map((id) => BigInt(id)))]; - if (unique.includes(primaryId)) { - throw new BadRequestException( - 'mergedOriginGoodIds 不能包含主源 originGoodId', - ); - } - return unique; - } - - private async ensureMergedOriginGoods(ids: bigint[]) { - if (ids.length === 0) return; - const rows = await this.prisma.originGood.findMany({ - where: { id: { in: ids } }, - select: { id: true }, - }); - if (rows.length !== ids.length) { - const found = new Set(rows.map((r) => r.id.toString())); - const missing = ids.find((id) => !found.has(id.toString())); - throw new BadRequestException(`Origin good ${missing} not found`); - } - } - - private async ensureCountry(id: number) { - const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } }); - if (!c) throw new BadRequestException(`Country ${id} not found`); - } - - private async ensureCategory(id: number) { - const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } }); - if (!c) throw new BadRequestException(`Category ${id} not found`); - } - - /** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */ - private async stripAutoGroupTags( - tagIds: number[], - familyId: bigint | null, - ): Promise { - if (!familyId || !tagIds.length) return tagIds; - const groups = await this.prisma.tagGroup.findMany({ - select: { id: true, groupName: true }, - }); - 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 }, - }); - 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`); - } - - private async ensureReferences(dto: CreateGoodDto) { - await this.ensureOriginGood(dto.originGoodId); - await this.ensureCountry(dto.countryId); - await this.ensureCategory(dto.categoryId); - if (dto.tagIds && dto.tagIds.length > 0) { - for (const tagId of dto.tagIds) { - await this.ensureTag(tagId); - } - } - if (dto.positionId !== undefined) { - const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } }); - if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); - } - } - - private async ensurePosition(id: number) { - const position = await this.prisma.position.findUnique({ - where: { id: BigInt(id) }, - }); - if (!position) throw new BadRequestException(`Position ${id} not found`); - } - - private decimal(value: string | null | undefined): Prisma.Decimal | null { - return value === undefined || value === null || value === '' - ? null - : new Prisma.Decimal(value); - } - - private customDetailData( - detail: CustomGoodDetailDto, - preserveMissing = false, - ): Prisma.OriginGoodDetailUncheckedUpdateInput { - const nullable = (value: T | null | undefined): T | null | undefined => - preserveMissing && value === undefined ? undefined : value ?? null; - const decimal = (value: string | null | undefined) => - preserveMissing && value === undefined ? undefined : this.decimal(value); - const json = ( - value: Record | null | undefined, - ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => - preserveMissing && value === undefined - ? undefined - : value === null || value === undefined - ? Prisma.DbNull - : (value as Prisma.InputJsonValue); - return { - productCode: nullable(detail.productCode), - englishName: nullable(detail.englishName), - blankDesignUrl: nullable(detail.blankDesignUrl), - detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), - textureName: nullable(detail.textureName), - productionCycleHours: nullable(detail.productionCycleHours), - minWeightG: decimal(detail.minWeightG), - reminder: nullable(detail.reminder), - productionProcess: nullable(detail.productionProcess), - materialDescription: nullable(detail.materialDescription), - productPerformance: nullable(detail.productPerformance), - applicableScenarios: nullable(detail.applicableScenarios), - washingInstructions: nullable(detail.washingInstructions), - specialDescription: nullable(detail.specialDescription), - designExplanation: nullable(detail.designExplanation), - designArea: nullable(detail.designArea), - pictureRequest: nullable(detail.pictureRequest), - sizeChart: json(detail.sizeChart), - packageSpecs: json(detail.packageSpecs), - options: json(detail.options), - media: json(detail.media), - }; - } - - private async replaceCustomVariants( - tx: Prisma.TransactionClient, - originGoodId: bigint, - variants: CustomGoodVariantDto[], - ): Promise { - await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); - for (const variant of variants) { - await tx.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `custom-${randomUUID()}`, - sku: variant.sku, - sizeId: variant.sizeId ?? null, - sizeName: variant.sizeName ?? null, - colorId: variant.colorId ?? null, - colorName: variant.colorName ?? null, - colorHex: variant.colorHex ?? null, - imageUrl: variant.imageUrl ?? null, - price: this.decimal(variant.price), - originalPrice: this.decimal(variant.originalPrice), - weightG: this.decimal(variant.weightG), - boxLengthCm: this.decimal(variant.boxLengthCm), - boxWidthCm: this.decimal(variant.boxWidthCm), - boxHeightCm: this.decimal(variant.boxHeightCm), - enabled: variant.enabled ?? true, - sortOrder: variant.sortOrder ?? 0, - designData: - variant.designData === null || variant.designData === undefined - ? Prisma.DbNull - : (variant.designData as Prisma.InputJsonValue), - }, - }); - } - } -} +import { Prisma } from '@prisma/client'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { parseOriginName } from '../product-families/origin-name.parser'; +import { CreateGoodDto } from './dto/create-good.dto'; +import { UpdateGoodDto } from './dto/update-good.dto'; +import { QueryGoodDto } from './dto/query-good.dto'; +import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; +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, + CustomGoodDetailDto, + CustomGoodVariantDto, + UpdateCustomGoodContentDto, +} from './dto/custom-good.dto'; + +const GOOD_INCLUDE = { + country: true, + category: true, + tag: true, + position: true, + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + family: { select: { id: true, familyCode: true, familyName: true, stale: true } }, + }, + }, + goodTags: { include: { tag: true } }, + mergedOriginGoods: { + orderBy: { createdAt: 'asc' }, + include: { + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + _count: { select: { variants: true } }, + }, + }, + }, + }, +} satisfies Prisma.GoodInclude; + +/** + * 商品名(公开展示名)规范化:结构完整的原始链接名(`国家(物流)品名-型号-…`, + * 含半角/全角括号混用)一律存为「品名 型号」;已解析名与自由命名的自定义名原样保留。 + * 防的是绕过前端弹窗(预填解析名)的写入路径——API 直调、批量工具、旧客户端。 + */ +export function normalizeGoodName(name: string): string { + const parsed = parseOriginName(name); + if (parsed.productName && parsed.skuCode) { + return `${parsed.productName} ${parsed.skuCode}`; + } + return name; +} + +@Injectable() +export class GoodsService { + constructor( + private readonly prisma: PrismaService, + private readonly syncService: SyncService, + private readonly familyRecompute: FamilyRecomputeService, + ) {} + + async findAll(query: QueryGoodDto): Promise { + const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query; + const where: Prisma.GoodWhereInput = {}; + if (countryId !== undefined) where.countryId = BigInt(countryId); + if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } }; + if (positionId !== undefined) where.positionId = BigInt(positionId); + if (keyword) { + where.goodName = { contains: keyword, mode: 'insensitive' }; + } + if (categoryId !== undefined) { + const ids = await this.collectCategoryDescendants(BigInt(categoryId)); + where.categoryId = { in: ids }; + } + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.good.count({ where }), + this.prisma.good.findMany({ + where, + include: GOOD_INCLUDE, + orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + + return { + items: rows.map((g) => GoodDto.from(g, { + country: g.country, + category: g.category, + tag: g.tag, + position: g.position, + originGood: g.originGood, + goodTags: g.goodTags, + mergedOriginGoods: g.mergedOriginGoods, + })), + total, + page, + pageSize, + }; + } + + async findOne(id: bigint): Promise { + const good = await this.prisma.good.findUnique({ + where: { id }, + include: GOOD_INCLUDE, + }); + if (!good) throw new NotFoundException(`Good ${id} not found`); + return GoodDetailDto.fromGood(good, { + country: good.country, + category: good.category, + tag: good.tag, + position: good.position, + originGood: good.originGood, + goodTags: good.goodTags, + mergedOriginGoods: good.mergedOriginGoods, + }); + } + + async create(dto: CreateGoodDto): Promise { + await this.ensureReferences(dto); + const mergedIds = this.dedupeMergedIds( + BigInt(dto.originGoodId), + dto.mergedOriginGoodIds, + ); + await this.ensureMergedOriginGoods(mergedIds); + const result = await this.prisma.$transaction(async (tx) => { + // Good 的族是派生数据:主链接所属族 + const primary = await tx.originGood.findUnique({ + 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: normalizeGoodName(dto.goodName), + goodImage: dto.goodImage, + originGoodId: BigInt(dto.originGoodId), + familyId: primary?.familyId ?? null, + countryId: BigInt(dto.countryId), + categoryId: BigInt(dto.categoryId), + positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), + goodPriority: dto.goodPriority ?? 0, + }, + }); + if (tagIds.length > 0) { + await tx.goodTag.createMany({ + data: tagIds.map((tagId) => ({ + goodId: created.id, + tagId: BigInt(tagId), + })), + }); + } + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: created.id, + originGoodId, + })), + }); + } + const result = await tx.good.findUniqueOrThrow({ + where: { id: created.id }, + include: GOOD_INCLUDE, + }); + return GoodDto.from(result, { + country: result.country, + category: result.category, + tag: result.tag, + position: result.position, + originGood: result.originGood, + goodTags: result.goodTags, + mergedOriginGoods: result.mergedOriginGoods, + }); + }); + if (result.originGood) { + // 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程) + await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId)); + } + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; + } + + async createCustom(dto: CreateCustomGoodDto): Promise { + await this.ensureCountry(dto.countryId); + await this.ensureCategory(dto.categoryId); + if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); + for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); + let family: { id: bigint } | null = null; + if (dto.familyId !== undefined) { + family = await this.prisma.productFamily.findUnique({ + where: { id: BigInt(dto.familyId) }, + select: { id: true }, + }); + if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`); + } + + const goodId = await this.prisma.$transaction(async (tx) => { + const originGood = await tx.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: `custom-${randomUUID()}`, + goodName: dto.goodName, + goodImage: dto.goodImage ?? null, + goodPrice: this.decimal(dto.goodPrice), + ...(family ? { familyId: family.id } : {}), + ...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined + ? { + logisticsLabel: dto.logisticsLabel ?? null, + craftLabel: dto.craftLabel ?? null, + skuCode: dto.skuCode ?? null, + warehouseLabel: dto.warehouseLabel ?? null, + } + : {}), + detail: { + create: this.customDetailData( + dto.detail ?? {}, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput, + }, + }, + }); + if (dto.variants?.length) { + await this.replaceCustomVariants(tx, originGood.id, dto.variants); + } + const good = await tx.good.create({ + data: { + originGoodId: originGood.id, + familyId: family?.id ?? null, + countryId: BigInt(dto.countryId), + categoryId: BigInt(dto.categoryId), + positionId: + dto.positionId === undefined ? null : BigInt(dto.positionId), + goodName: normalizeGoodName(dto.goodName), + goodImage: dto.goodImage ?? null, + goodPriority: dto.goodPriority ?? 0, + }, + }); + if (dto.tagIds?.length) { + await tx.goodTag.createMany({ + data: dto.tagIds.map((tagId) => ({ + goodId: good.id, + tagId: BigInt(tagId), + })), + }); + } + return good.id; + }); + if (family) this.familyRecompute.enqueue(family.id); + return this.findOne(goodId); + } + + async updateCustomContent( + id: bigint, + dto: UpdateCustomGoodContentDto, + ): Promise { + const existing = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!existing) throw new NotFoundException(`Good ${id} not found`); + if (existing.originGood.source !== 'CUSTOM') { + throw new BadRequestException('SDS 映射商品的上游信息不可修改'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.originGood.update({ + where: { id: existing.originGoodId }, + data: { + goodName: dto.goodName, + goodImage: dto.goodImage, + goodPrice: + dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice), + }, + }); + if (dto.detail !== undefined) { + await tx.originGoodDetail.upsert({ + where: { originGoodId: existing.originGoodId }, + create: { + originGoodId: existing.originGoodId, + ...(this.customDetailData( + dto.detail, + ) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput), + }, + update: this.customDetailData(dto.detail, true), + }); + } + if (dto.variants !== undefined) { + await this.replaceCustomVariants( + tx, + existing.originGoodId, + dto.variants, + ); + } + const goodData: Prisma.GoodUpdateInput = {}; + if (dto.goodName !== undefined) { + goodData.goodName = normalizeGoodName(dto.goodName); + } + if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; + if (Object.keys(goodData).length) { + await tx.good.update({ where: { id }, data: goodData }); + } + }); + return this.findOne(id); + } + + async update(id: bigint, dto: UpdateGoodDto): Promise { + await this.findOne(id); + let mergedIds: bigint[] | undefined; + if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) { + const current = await this.prisma.good.findUniqueOrThrow({ + where: { id }, + select: { originGoodId: true }, + }); + const primaryId = + dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId; + mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds); + await this.ensureMergedOriginGoods(mergedIds); + } + const data: Prisma.GoodUpdateInput = {}; + if (dto.goodName !== undefined) data.goodName = normalizeGoodName(dto.goodName); + if (dto.originGoodId !== undefined) { + await this.ensureOriginGood(dto.originGoodId); + data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; + } + if (dto.countryId !== undefined) { + await this.ensureCountry(dto.countryId); + data.country = { connect: { id: BigInt(dto.countryId) } }; + } + if (dto.categoryId !== undefined) { + await this.ensureCategory(dto.categoryId); + data.category = { connect: { id: BigInt(dto.categoryId) } }; + } + if (dto.positionId !== undefined) { + data.position = + dto.positionId === null + ? { disconnect: true } + : { connect: { id: BigInt(dto.positionId) } }; + } + if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority; + if (dto.goodImage !== undefined) data.goodImage = dto.goodImage; + if (dto.familyId !== undefined) { + if (dto.familyId !== null) { + const family = await this.prisma.productFamily.findUnique({ + where: { id: BigInt(dto.familyId) }, + select: { id: true }, + }); + if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`); + data.family = { connect: { id: family.id } }; + } else { + data.family = { disconnect: true }; + } + } + if (dto.tagIds !== undefined) { + for (const tagId of dto.tagIds) { + await this.ensureTag(tagId); + } + } + + // 有族商品的自动组(物流/工艺/位置)标签为族派生数据,不接受手动写入 + 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 (updateTagIds !== undefined) { + await tx.goodTag.deleteMany({ where: { goodId: id } }); + if (updateTagIds.length > 0) { + await tx.goodTag.createMany({ + data: updateTagIds.map((tagId) => ({ + goodId: id, + tagId: BigInt(tagId), + })), + }); + } + } + if (mergedIds !== undefined) { + await tx.goodOriginGood.deleteMany({ where: { goodId: id } }); + if (mergedIds.length > 0) { + await tx.goodOriginGood.createMany({ + data: mergedIds.map((originGoodId) => ({ + goodId: id, + originGoodId, + })), + }); + } + } + const updated = await tx.good.update({ + where: { id }, + data, + include: GOOD_INCLUDE, + }); + return GoodDto.from(updated, { + country: updated.country, + category: updated.category, + tag: updated.tag, + position: updated.position, + originGood: updated.originGood, + goodTags: updated.goodTags, + mergedOriginGoods: updated.mergedOriginGoods, + }); + }); + if (result.originGood) { + // 更新后把链接有效标签镜像到该商品(纯聚合,不派生) + await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId)); + } + if (familyIdForTags) { + // 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合) + await this.familyRecompute.recomputeFamily(familyIdForTags); + } + if ( + result.originGood?.source === 'SDS' && + result.originGood.sdsGoodId && + !result.originGood.hasDetail + ) { + this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); + } + return result; + } + + async remove(id: bigint): Promise<{ id: string }> { + const good = await this.prisma.good.findUnique({ + where: { id }, + include: { originGood: true }, + }); + if (!good) throw new NotFoundException(`Good ${id} not found`); + await this.prisma.$transaction(async (tx) => { + await tx.good.delete({ where: { id } }); + if (good.originGood.source === 'CUSTOM') { + const remaining = await tx.good.count({ + where: { originGoodId: good.originGoodId }, + }); + if (remaining === 0) { + await tx.originGood.delete({ where: { id: good.originGoodId } }); + } + } + }); + return { id: id.toString() }; + } + + /** + * Updates priorities in a single transaction; either all rows update + * or none do. + */ + async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> { + const result = await this.prisma.$transaction(async (tx) => { + for (const item of dto.items) { + await tx.good.update({ + where: { id: BigInt(item.id) }, + data: { goodPriority: item.priority }, + }); + } + return { count: dto.items.length }; + }); + return result; + } + + /** + * Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId + * and a default priority that may be overridden per item. + */ + async batchCreate(dto: BatchCreateGoodDto): Promise { + const defaultPriority = dto.defaultPriority ?? 0; + if (dto.tagIds && dto.tagIds.length > 0) { + for (const tagId of dto.tagIds) { + await this.ensureTag(tagId); + } + } + const result = await this.prisma.$transaction(async (tx) => { + const created: GoodDto[] = []; + for (const item of dto.items) { + const og = await tx.originGood.findUnique({ + where: { id: BigInt(item.originGoodId) }, + }); + if (!og) { + throw new BadRequestException( + `Origin good ${item.originGoodId} not found`, + ); + } + const row = await tx.good.create({ + data: { + goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, + goodImage: og.goodImage, + originGoodId: og.id, + familyId: og.familyId, + countryId: BigInt(dto.countryId), + categoryId: BigInt(dto.categoryId), + positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), + goodPriority: item.priority ?? defaultPriority, + }, + }); + if (dto.tagIds && dto.tagIds.length > 0) { + await tx.goodTag.createMany({ + data: dto.tagIds.map((tagId) => ({ + goodId: row.id, + tagId: BigInt(tagId), + })), + }); + } + const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds); + if (itemMerged.length > 0) { + const existRows = await tx.originGood.findMany({ + where: { id: { in: itemMerged } }, + select: { id: true }, + }); + if (existRows.length !== itemMerged.length) { + const found = new Set(existRows.map((r) => r.id.toString())); + const missing = itemMerged.find((mid) => !found.has(mid.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + await tx.goodOriginGood.createMany({ + data: itemMerged.map((originGoodId) => ({ + goodId: row.id, + originGoodId, + })), + }); + } + const result = await tx.good.findUniqueOrThrow({ + where: { id: row.id }, + include: GOOD_INCLUDE, + }); + created.push(GoodDto.from(result, { + country: result.country, + category: result.category, + tag: result.tag, + position: result.position, + originGood: result.originGood, + goodTags: result.goodTags, + mergedOriginGoods: result.mergedOriginGoods, + })); + } + return created; + }); + for (const goodId of new Set( + result + .filter( + (item) => + item.originGood?.source === 'SDS' && + !item.originGood.hasDetail, + ) + .map((item) => item.originGood?.sdsGoodId) + .filter((id): id is string => Boolean(id)), + )) { + this.syncService.queueProductDetailSync(goodId); + } + return result; + } + + /** + * Walk the category tree and return the requested id + all of its + * descendants. We use a level-by-level BFS to keep the queries small + * for the typical tree sizes we expect. + */ + private async collectCategoryDescendants(rootId: bigint): Promise { + const ids: bigint[] = [rootId]; + let frontier: bigint[] = [rootId]; + while (frontier.length > 0) { + const children = await this.prisma.category.findMany({ + where: { parentCategoryId: { in: frontier } }, + select: { id: true }, + }); + if (children.length === 0) break; + const childIds = children.map((c) => c.id); + ids.push(...childIds); + frontier = childIds; + } + return ids; + } + + private async ensureOriginGood(id: number) { + const og = await this.prisma.originGood.findUnique({ + where: { id: BigInt(id) }, + }); + if (!og) throw new BadRequestException(`Origin good ${id} not found`); + } + + /** Dedupe merged ids and reject any that equals the primary source. */ + private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] { + if (!ids || ids.length === 0) return []; + const unique = [...new Set(ids.map((id) => BigInt(id)))]; + if (unique.includes(primaryId)) { + throw new BadRequestException( + 'mergedOriginGoodIds 不能包含主源 originGoodId', + ); + } + return unique; + } + + private async ensureMergedOriginGoods(ids: bigint[]) { + if (ids.length === 0) return; + const rows = await this.prisma.originGood.findMany({ + where: { id: { in: ids } }, + select: { id: true }, + }); + if (rows.length !== ids.length) { + const found = new Set(rows.map((r) => r.id.toString())); + const missing = ids.find((id) => !found.has(id.toString())); + throw new BadRequestException(`Origin good ${missing} not found`); + } + } + + private async ensureCountry(id: number) { + const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } }); + if (!c) throw new BadRequestException(`Country ${id} not found`); + } + + private async ensureCategory(id: number) { + const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } }); + if (!c) throw new BadRequestException(`Category ${id} not found`); + } + + /** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */ + private async stripAutoGroupTags( + tagIds: number[], + familyId: bigint | null, + ): Promise { + if (!familyId || !tagIds.length) return tagIds; + const groups = await this.prisma.tagGroup.findMany({ + select: { id: true, groupName: true }, + }); + 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 }, + }); + 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`); + } + + private async ensureReferences(dto: CreateGoodDto) { + await this.ensureOriginGood(dto.originGoodId); + await this.ensureCountry(dto.countryId); + await this.ensureCategory(dto.categoryId); + if (dto.tagIds && dto.tagIds.length > 0) { + for (const tagId of dto.tagIds) { + await this.ensureTag(tagId); + } + } + if (dto.positionId !== undefined) { + const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } }); + if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); + } + } + + private async ensurePosition(id: number) { + const position = await this.prisma.position.findUnique({ + where: { id: BigInt(id) }, + }); + if (!position) throw new BadRequestException(`Position ${id} not found`); + } + + private decimal(value: string | null | undefined): Prisma.Decimal | null { + return value === undefined || value === null || value === '' + ? null + : new Prisma.Decimal(value); + } + + private customDetailData( + detail: CustomGoodDetailDto, + preserveMissing = false, + ): Prisma.OriginGoodDetailUncheckedUpdateInput { + const nullable = (value: T | null | undefined): T | null | undefined => + preserveMissing && value === undefined ? undefined : value ?? null; + const decimal = (value: string | null | undefined) => + preserveMissing && value === undefined ? undefined : this.decimal(value); + const json = ( + value: Record | null | undefined, + ): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined => + preserveMissing && value === undefined + ? undefined + : value === null || value === undefined + ? Prisma.DbNull + : (value as Prisma.InputJsonValue); + return { + productCode: nullable(detail.productCode), + englishName: nullable(detail.englishName), + blankDesignUrl: nullable(detail.blankDesignUrl), + detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl), + textureName: nullable(detail.textureName), + productionCycleHours: nullable(detail.productionCycleHours), + minWeightG: decimal(detail.minWeightG), + reminder: nullable(detail.reminder), + productionProcess: nullable(detail.productionProcess), + materialDescription: nullable(detail.materialDescription), + productPerformance: nullable(detail.productPerformance), + applicableScenarios: nullable(detail.applicableScenarios), + washingInstructions: nullable(detail.washingInstructions), + specialDescription: nullable(detail.specialDescription), + designExplanation: nullable(detail.designExplanation), + designArea: nullable(detail.designArea), + pictureRequest: nullable(detail.pictureRequest), + sizeChart: json(detail.sizeChart), + packageSpecs: json(detail.packageSpecs), + options: json(detail.options), + media: json(detail.media), + }; + } + + private async replaceCustomVariants( + tx: Prisma.TransactionClient, + originGoodId: bigint, + variants: CustomGoodVariantDto[], + ): Promise { + await tx.originGoodVariant.deleteMany({ where: { originGoodId } }); + for (const variant of variants) { + await tx.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `custom-${randomUUID()}`, + sku: variant.sku, + sizeId: variant.sizeId ?? null, + sizeName: variant.sizeName ?? null, + colorId: variant.colorId ?? null, + colorName: variant.colorName ?? null, + colorHex: variant.colorHex ?? null, + imageUrl: variant.imageUrl ?? null, + price: this.decimal(variant.price), + originalPrice: this.decimal(variant.originalPrice), + weightG: this.decimal(variant.weightG), + boxLengthCm: this.decimal(variant.boxLengthCm), + boxWidthCm: this.decimal(variant.boxWidthCm), + boxHeightCm: this.decimal(variant.boxHeightCm), + enabled: variant.enabled ?? true, + sortOrder: variant.sortOrder ?? 0, + designData: + variant.designData === null || variant.designData === undefined + ? Prisma.DbNull + : (variant.designData as Prisma.InputJsonValue), + }, + }); + } + } +} 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 63ae85c..1e2f335 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -1,6 +1,8 @@ import { Test } from '@nestjs/testing'; import { OriginGoodsService } from './origin-goods.service'; -import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { ProductFamiliesService } from '../product-families/product-families.service'; +import { OrganizeService } from '../product-families/organize.service'; import { PrismaService } from '../prisma/prisma.service'; describe('OriginGoodsService', () => { @@ -11,7 +13,7 @@ describe('OriginGoodsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [OriginGoodsService, FamilyRecomputeService, PrismaService], + providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, 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 a089fac..f4ad489 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -6,6 +6,7 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { OrganizeService } from '../product-families/organize.service'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; /** 链接标签(origin_good_tags 行,含人工/派生标记) */ @@ -89,6 +90,7 @@ export class OriginGoodsService { constructor( private readonly prisma: PrismaService, private readonly familyRecompute: FamilyRecomputeService, + private readonly organize: OrganizeService, ) {} /** 链接当前标签(含 manual 标记) */ @@ -152,10 +154,16 @@ export class OriginGoodsService { this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }), ]); await this.familyRecompute.mirrorLinkTagsToGoods(id); + // 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算) + const ogFull = await this.prisma.originGood.findUnique({ + where: { id }, + select: { familyId: true }, + }); + if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId); return this.getTags(id); } - /** 恢复自动:清掉全部标签行(含人工行),回到按链接名称派生 */ + /** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */ async resetTags(id: bigint): Promise { const og = await this.prisma.originGood.findUnique({ where: { id }, @@ -166,12 +174,9 @@ export class OriginGoodsService { 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); - } + // 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作 + await this.organize.deriveTagsForOg(id); + if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId); return this.getTags(id); } diff --git a/apps/api/src/product-families/auto-tag-rules.spec.ts b/apps/api/src/product-families/auto-tag-rules.spec.ts index 1bf09ce..8ce5bef 100644 --- a/apps/api/src/product-families/auto-tag-rules.spec.ts +++ b/apps/api/src/product-families/auto-tag-rules.spec.ts @@ -32,8 +32,12 @@ describe('auto-tag-rules / deriveLinkTagNames', () => { expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']); }); - it('直喷命中时不给默认烫画', () => { - expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['直喷', '包邮']); + it('直喷命中时不给默认烫画;名称未写单/双面时印花数量默认单面印花', () => { + expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['单面印花', '直喷', '包邮']); + }); + + it('不打印/光板(无印花面)不补默认印花数量', () => { + expect(deriveLinkTagNames('美国(不包邮光板)T恤-DG001-不打印')).toEqual(['不打印', '不包邮']); }); it('物流备注既无包邮也无不包邮时不下发物流标签', () => { diff --git a/apps/api/src/product-families/auto-tag-rules.ts b/apps/api/src/product-families/auto-tag-rules.ts index 371e5b5..f03160f 100644 --- a/apps/api/src/product-families/auto-tag-rules.ts +++ b/apps/api/src/product-families/auto-tag-rules.ts @@ -48,14 +48,16 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] { const names: string[] = []; // 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」; - // 双面优先判断,避免「单面」误吞 - if (name.includes('双面')) names.push('双面印花'); - else if (name.includes('单面')) names.push('单面印花'); - + // 双面优先判断,避免「单面」误吞。名称完全没写且工艺非「不打印」时默认单面印花 + // (解析假设集中在整理脚本层,可审查可重跑;不打印/光板 无印花面,不补) const craftTags = new Set(); for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) { if (name.includes(keyword)) craftTags.add(tagName); } + const noPrint = craftTags.has('不打印'); + if (name.includes('双面')) names.push('双面印花'); + else if (name.includes('单面')) names.push('单面印花'); + else if (!noPrint) names.push('单面印花'); if (craftTags.size > 0) names.push(...craftTags); else names.push(CRAFT_DEFAULT); diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index 159110c..9c80615 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -1,6 +1,8 @@ import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; import { FamilyRecomputeService } from './family-recompute.service'; +import { ProductFamiliesService } from './product-families.service'; +import { OrganizeService } from './organize.service'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -10,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service'; */ describe('FamilyRecomputeService', () => { let service: FamilyRecomputeService; + let organize!: OrganizeService; let prisma: PrismaService; const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; @@ -19,6 +22,7 @@ describe('FamilyRecomputeService', () => { const mkOriginGood = async (over: { goodName?: string; + source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null; variants?: Array<{ @@ -38,7 +42,7 @@ describe('FamilyRecomputeService', () => { data: { sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`, goodName: over.goodName ?? `测试链接-${stamp}`, - source: 'CUSTOM', + source: over.source ?? 'CUSTOM', craftLabel: over.craftLabel ?? null, logisticsLabel: over.logisticsLabel ?? null, }, @@ -98,6 +102,11 @@ describe('FamilyRecomputeService', () => { providers: [FamilyRecomputeService, PrismaService], }).compile(); service = moduleRef.get(FamilyRecomputeService); + organize = new OrganizeService( + moduleRef.get(PrismaService), + service, + new ProductFamiliesService(moduleRef.get(PrismaService), service), + ); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); }); @@ -141,8 +150,9 @@ describe('FamilyRecomputeService', () => { expect(after.stale).toBe(false); }); - it('价格矩阵:五维格子取最低价并累积来源;维度取链接标签、名称回退;停用变体不参与', async () => { + it('价格矩阵:五维格子取最低价并累积来源;维度取整理派生的链接标签;停用变体不参与', async () => { const a = await mkOriginGood({ + source: 'SDS', goodName: '美国(包邮)测试A-PA1-单面印花', variants: [ { sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, @@ -151,6 +161,7 @@ describe('FamilyRecomputeService', () => { ], }); const b = await mkOriginGood({ + source: 'SDS', goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库) variants: [ { sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 }, @@ -158,19 +169,23 @@ describe('FamilyRecomputeService', () => { ], }); const d = await mkOriginGood({ + source: 'SDS', goodName: '美国(包邮)测试D-PD1-直喷双面', variants: [ { sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 }, ], }); const e = await mkOriginGood({ - goodName: '美国(不包邮)测试E-PE1-光板', // 无标签行 → 名称回退:不打印 + 单面印花默认 + source: 'SDS', + goodName: '美国(不包邮)测试E-PE1-光板', // 整理派生:不打印 + 不包邮(无印花数量标签 → 结构化占位单面) variants: [ { sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 }, ], }); const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] }); + // 整理(显式派生标签)→ 重算(纯聚合)。运行时不解析名称,未派生的成员不进矩阵 + await organize.deriveFamilyTags(family.id); await service.recomputeFamily(family.id); const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); @@ -185,7 +200,7 @@ describe('FamilyRecomputeService', () => { // 直喷双面 → 印花数量/工艺两维正确拆分 const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30'); expect(dg).toBeTruthy(); - // 光板(无标签)→ 工艺=不打印、印花数量回退单面、物流=不包邮 + // 光板 → 工艺=不打印、印花数量占位单面、物流=不包邮 const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮'); expect(blank).toBeTruthy(); expect(blank.printCount).toBe('单面印花'); @@ -195,7 +210,27 @@ describe('FamilyRecomputeService', () => { expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']); }); - it('价格矩阵:人工接管标签优先于名称派生', async () => { + it('价格矩阵:未整理(无标签)的 SDS 成员不进矩阵', async () => { + const a = await mkOriginGood({ + source: 'SDS', + goodName: '美国(包邮)测试U-PU1-单面印花', + variants: [ + { sdsVariantId: 'v1', sku: 'U-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }, + ], + }); + const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] }); + // 只重算、不整理 → 无标签 → 空矩阵 + await service.recomputeFamily(family.id); + let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((after.priceMatrix as any).rows).toHaveLength(0); + // 整理后进入矩阵 + await organize.deriveFamilyTags(family.id); + await service.recomputeFamily(family.id); + after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((after.priceMatrix as any).rows).toHaveLength(1); + }); + + it('价格矩阵:人工接管标签即权威维度(人工标签 > 一切)', async () => { const a = await mkOriginGood({ goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮 variants: [ diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index 488c70e..f7ff66c 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -1,11 +1,7 @@ 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'; +import { isAutoTagGroupName } from './auto-tag-rules'; /** * 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。 @@ -82,38 +78,38 @@ export interface MatrixCombo { } /** - * 成员在矩阵中的归因维度组合(纯函数),取值优先级: + * 成员在矩阵中的归因维度组合(纯函数),取值只有两个来源—— * 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签); - * 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本); - * 3. 链接名称派生(deriveLinkTagNames); - * 4. 组内默认值(单面印花 / 烫画 / 包邮)。 + * 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本)。 + * 【不解析名称】:SDS 成员无标签 → 返回空(不进矩阵),由整理(OrganizeService)显式补标签。 * 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。 */ export function memberMatrixCombos(input: { - goodName: string | null; tagNames: string[]; customLabels?: { craft?: string | null; logistics?: string | null }; }): MatrixCombo[] { - const derived = deriveLinkTagNames(input.goodName); - const values = (dim: DimKey): string[] => { - const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v)); - if (fromTags.length) return [...fromTags]; - const label = - dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics; - if (dim !== 'printCount' && label) return [label]; - const fromName = derived.filter((n) => - (DIM_VALUES[dim] as readonly string[]).includes(n), - ); - if (fromName.length) return fromName; - if (dim === 'printCount') { - const craftLabel = input.customLabels?.craft ?? ''; - return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]]; - } - return [DIM_VALUES[dim][0]]; - }; - const printCounts = values('printCount'); - const crafts = values('craft'); - const logistics = values('logistics'); + const values = (dim: DimKey): string[] => + DIM_VALUES[dim].filter((v) => input.tagNames.includes(v)); + + let printCounts = values('printCount'); + let crafts = values('craft'); + let logistics = values('logistics'); + // CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本) + const craftLabel = input.customLabels?.craft ?? ''; + const logisticsLabel = input.customLabels?.logistics ?? ''; + if (!crafts.length && craftLabel) crafts = [craftLabel]; + if (!logistics.length && logisticsLabel) logistics = [logisticsLabel]; + // 结构化占位规则(非名称解析):工艺=不打印 时印花面不存在,印花数量固定单面占位, + // 保证光板链接的价格进矩阵;工艺=烫画/直喷 而缺印花数量标签 → 缺维度不进矩阵(等整理补标签) + const noPrintCraft = + crafts.includes('不打印') || craftLabel.includes('不打印') || craftLabel.includes('光板'); + if (!printCounts.length && noPrintCraft) { + printCounts = [DIM_VALUES.printCount[0]]; + } else if (!printCounts.length && craftLabel.includes('双面')) { + printCounts = ['双面印花']; + } + if (!printCounts.length || !crafts.length || !logistics.length) return []; + const combos: MatrixCombo[] = []; for (const printCount of printCounts) { for (const craft of crafts) { @@ -168,7 +164,6 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): const memberCombos = members.map((member) => ({ member, combos: memberMatrixCombos({ - goodName: member.goodName, tagNames: member.originGoodTags.map((r) => r.tag.tagName), // CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源 customLabels: @@ -389,167 +384,7 @@ export class FamilyRecomputeService { }); } - // 派生标签同步(无论是否锁定:标签是派生数据而非人工策展) - await this.syncFamilyTags(family.id); } - - /** - * 族 → 标签同步(标签与「产品链接」一一对应): - * 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新 - * origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留; - * 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。 - * 仅更新发生变化的行,幂等。 - */ - 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: { id: true } }, - }, - }); - 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 }, - }); - const autoGroupIds = new Set( - groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()), - ); - - 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 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 })), - }), - ]), - ]); - goodsUpdated += 1; - } - 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({ @@ -594,62 +429,5 @@ export class FamilyRecomputeService { } } - 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,天然去重) */ - private async ensureDerivedTagMap(): Promise> { - const map = new Map(); - 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; - } } diff --git a/apps/api/src/product-families/family-tag-sync.spec.ts b/apps/api/src/product-families/organize.service.spec.ts similarity index 76% rename from apps/api/src/product-families/family-tag-sync.spec.ts rename to apps/api/src/product-families/organize.service.spec.ts index 1f49636..e85fca1 100644 --- a/apps/api/src/product-families/family-tag-sync.spec.ts +++ b/apps/api/src/product-families/organize.service.spec.ts @@ -1,16 +1,19 @@ import { Test } from '@nestjs/testing'; import { FamilyRecomputeService } from './family-recompute.service'; +import { ProductFamiliesService } from './product-families.service'; +import { OrganizeService } from './organize.service'; import { OriginGoodsService } from '../origin-goods/origin-goods.service'; import { PrismaService } from '../prisma/prisma.service'; /** - * 标签与「产品链接」一一对应: + * 整理服务(解析去运行时化后的唯一派生入口): * 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tags(manual=false); * 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签); * 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。 */ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { let recompute: FamilyRecomputeService; + let organize: OrganizeService; let originGoods: OriginGoodsService; let prisma: PrismaService; const stamp = Date.now(); @@ -26,9 +29,10 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [FamilyRecomputeService, OriginGoodsService, PrismaService], + providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService], }).compile(); recompute = moduleRef.get(FamilyRecomputeService); + organize = moduleRef.get(OrganizeService); originGoods = moduleRef.get(OriginGoodsService); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); @@ -119,7 +123,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } }); } - const r1 = await recompute.syncFamilyTags(family.id); + const r1 = await organize.deriveFamilyTags(family.id); expect(r1.linksUpdated).toBe(2); expect(r1.goodsUpdated).toBe(2); @@ -137,7 +141,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { expect(names2).not.toContain('烫画'); // 幂等 - const r2 = await recompute.syncFamilyTags(family.id); + const r2 = await organize.deriveFamilyTags(family.id); expect(r2.linksUpdated).toBe(0); expect(r2.goodsUpdated).toBe(0); }); @@ -168,7 +172,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { }); ids.good.push(good.id); - await recompute.syncFamilyTags(family.id); + await organize.deriveFamilyTags(family.id); expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']); // 人工接管:解析错了(实际是直喷)→ 改成 直喷 @@ -182,7 +186,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']); // 再次族同步:人工行不被覆盖 - await recompute.syncFamilyTags(family.id); + await organize.deriveFamilyTags(family.id); expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']); expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']); @@ -218,7 +222,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { }); ids.good.push(good.id); - const r = await recompute.syncFamilyTags(family.id); + const r = await organize.deriveFamilyTags(family.id); expect(r.linksUpdated).toBe(0); expect(await goodTagNames(good.id)).toEqual([]); @@ -227,4 +231,56 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]); expect(await goodTagNames(good.id)).toEqual(['包邮']); }); + + it('族重算不解析不触碰标签:上游改名后重算,标签/矩阵维度保持不变(防倒灌)', async () => { + const og = await prisma.originGood.create({ + data: { + sdsGoodId: `tagsync-nodrift-${stamp}`, + goodName: `美国(包邮)防倒灌T恤-ND${stamp}-单面印花`, + }, + }); + ids.originGood.push(og.id); + await prisma.originGoodVariant.create({ + data: { + originGoodId: og.id, + sdsVariantId: `nd-v-${stamp}`, + sku: `ND${stamp}`, + sizeName: 'S', + price: 21, + }, + }); + 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 } }); + + // 整理派生一次 → 标签就位,矩阵有格子 + await organize.deriveFamilyTags(family.id); + await recompute.recomputeFamily(family.id); + const tagsBefore = await linkTagNames(og.id); + expect(tagsBefore).toEqual(['包邮', '烫画', '单面印花']); + let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((fam.priceMatrix as any).rows).toHaveLength(1); + + // 模拟上游改名(同步镜像只更新 goodName)→ 仅重算、不整理 + await prisma.originGood.update({ + where: { id: og.id }, + data: { goodName: '美国(包邮)防倒灌T恤-ND0000-直喷双面' }, + }); + await recompute.recomputeFamily(family.id); + + // 标签与矩阵维度保持派生时的快照,不随名称漂移 + expect(await linkTagNames(og.id)).toEqual(tagsBefore); + fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + const row = (fam.priceMatrix as any).rows[0]; + expect(row.printCount).toBe('单面印花'); + expect(row.craft).toBe('烫画'); + + // 显式整理后才按新名称刷新 + await organize.deriveFamilyTags(family.id); + const refreshed = await linkTagNames(og.id); + expect(refreshed).toHaveLength(3); + expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷'])); + }); }); diff --git a/apps/api/src/product-families/organize.service.ts b/apps/api/src/product-families/organize.service.ts new file mode 100644 index 0000000..7fb1b68 --- /dev/null +++ b/apps/api/src/product-families/organize.service.ts @@ -0,0 +1,341 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { FamilyRecomputeService } from './family-recompute.service'; +import { ProductFamiliesService } from './product-families.service'; +import { parseOriginName } from './origin-name.parser'; +import { + DERIVED_TAG_GROUP_SPECS, + deriveLinkTagNames, + isAutoTagGroupName, +} from './auto-tag-rules'; + +/** + * 整理服务(解析去运行时化的唯一解析入口): + * 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发—— + * CLI(pnpm --filter @inkreach/api organize)或后台「整理」按钮(POST /product-families/organize)。 + * + * 运行时(同步/重算/公开读)永不解析名称: + * - 同步 = 纯镜像;族重算 = 结构化聚合(成员变体 + 链接标签 + 覆盖价)。 + * + * 人工接管(tagsManual=true)的链接派生永不触碰;幂等可重跑。 + */ +@Injectable() +export class OrganizeService { + private readonly logger = new Logger(OrganizeService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly recompute: FamilyRecomputeService, + private readonly families: ProductFamiliesService, + ) {} + + async organize() { + const labels = await this.backfillLabels(); + const tags = await this.deriveAllTags(); + const grouped = await this.families.autoGroup(true); + const families = await this.prisma.productFamily.findMany({ select: { id: true } }); + for (const f of families) { + await this.recompute.recomputeFamily(f.id); + } + const result = { + labelsParsed: labels.parsed, + labelsUnparsable: labels.unparsable, + linksUpdated: tags.linksUpdated, + goodsUpdated: tags.goodsUpdated, + familiesCreated: grouped.applied, + familiesRecomputed: families.length, + }; + this.logger.log(`organize done: ${JSON.stringify(result)}`); + return result; + } + + /** + * 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。 + * 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。 + */ + async backfillLabels(): Promise<{ parsed: number; unparsable: number }> { + const ogs = await this.prisma.originGood.findMany({ + where: { + OR: [ + { skuCode: null }, + { logisticsLabel: null }, + { craftLabel: null }, + { warehouseLabel: null }, + ], + }, + select: { id: true, goodName: true, skuCode: true, logisticsLabel: true, craftLabel: true, warehouseLabel: true }, + }); + let parsed = 0; + let unparsable = 0; + for (const og of ogs) { + const p = parseOriginName(og.goodName); + const next = { + skuCode: og.skuCode ?? p.skuCode, + logisticsLabel: og.logisticsLabel ?? p.logisticsLabel, + craftLabel: og.craftLabel ?? p.craftLabel, + warehouseLabel: og.warehouseLabel ?? p.warehouseLabel, + }; + if (!p.skuCode && !p.craftLabel) unparsable += 1; + await this.prisma.originGood.update({ where: { id: og.id }, data: next }); + parsed += 1; + } + return { parsed, unparsable }; + } + + /** 全量派生:未人工接管的 SDS 链接按名称刷新自动标签,人工接管不动;随后镜像商品并重算受影响族 */ + async deriveAllTags(): Promise<{ linksUpdated: number; goodsUpdated: number; familiesRecomputed: number }> { + const families = await this.prisma.productFamily.findMany({ + select: { id: true }, + orderBy: { id: 'asc' }, + }); + let linksUpdated = 0; + let goodsUpdated = 0; + const touchedFamilies: bigint[] = []; + for (const f of families) { + const r = await this.deriveFamilyTags(f.id); + linksUpdated += r.linksUpdated; + goodsUpdated += r.goodsUpdated; + if (r.linksUpdated > 0 || r.goodsUpdated > 0) touchedFamilies.push(f.id); + } + // 散链接(无族)也派生并镜像,保证族化之前标签已就绪 + const loose = await this.prisma.originGood.findMany({ + where: { familyId: null, delisted: false, source: 'SDS', tagsManual: false }, + select: { id: true }, + }); + for (const og of loose) { + const r = await this.deriveTagsForOg(og.id); + linksUpdated += r.linksUpdated; + } + for (const fid of touchedFamilies) { + await this.recompute.recomputeFamily(fid); + } + return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length }; + } + + /** + * 族 → 标签派生(标签与「产品链接」一一对应): + * 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新 + * origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留; + * 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。 + * 仅更新发生变化的行,幂等。 + */ + async deriveFamilyTags( + 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 }; + + 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; + } + + // 商品级镜像:派生写入后重新读取链接标签(上面的 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: { id: true } }, + }, + }); + 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 { autoGroupIds } = await this.autoGroupIds(); + 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 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 })), + }), + ]), + ]); + goodsUpdated += 1; + } + return { goodsUpdated, linksUpdated }; + } + + /** + * 单链接派生(供「恢复自动」等显式人工动作复用): + * 未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品。 + */ + async deriveTagsForOg(ogId: bigint): Promise<{ linksUpdated: number }> { + const og = await this.prisma.originGood.findUnique({ + where: { id: ogId }, + include: { originGoodTags: true }, + }); + if (!og) return { linksUpdated: 0 }; + let linksUpdated = 0; + 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, + })), + }), + ]), + ]); + linksUpdated = 1; + } + await this.recompute.mirrorLinkTagsToGoods(og.id); + return { linksUpdated }; + } + + private async autoGroupIds(): Promise<{ autoGroupIds: Set }> { + const groups = await this.prisma.tagGroup.findMany({ + select: { id: true, groupName: true }, + }); + return { + autoGroupIds: new Set( + groups + .filter((g) => isAutoTagGroupName(g.groupName)) + .map((g) => g.id.toString()), + ), + }; + } + + 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,天然去重) */ + private async ensureDerivedTagMap(): Promise> { + const map = new Map(); + 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; + } +} diff --git a/apps/api/src/product-families/product-families.controller.ts b/apps/api/src/product-families/product-families.controller.ts index cb82799..e1d5cd2 100644 --- a/apps/api/src/product-families/product-families.controller.ts +++ b/apps/api/src/product-families/product-families.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Q import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ProductFamiliesService } from './product-families.service'; +import { OrganizeService } from './organize.service'; import { AutoGroupDto, CreateCustomMemberDto, @@ -18,7 +19,10 @@ import { @UseGuards(JwtAuthGuard) @Controller('product-families') export class ProductFamiliesController { - constructor(private readonly service: ProductFamiliesService) {} + constructor( + private readonly service: ProductFamiliesService, + private readonly organize: OrganizeService, + ) {} @Get() @ApiOperation({ summary: 'List product families with keyword & pagination' }) @@ -26,6 +30,15 @@ export class ProductFamiliesController { return this.service.list(query); } + @Post('organize') + @ApiOperation({ + summary: + '整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算矩阵', + }) + organizeAll() { + return this.organize.organize(); + } + @Post('auto-group') @ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' }) autoGroup(@Body() dto: AutoGroupDto) { diff --git a/apps/api/src/product-families/product-families.module.ts b/apps/api/src/product-families/product-families.module.ts index 9334c37..7b9c8c9 100644 --- a/apps/api/src/product-families/product-families.module.ts +++ b/apps/api/src/product-families/product-families.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; import { FamilyRecomputeService } from './family-recompute.service'; +import { OrganizeService } from './organize.service'; import { ProductFamiliesController } from './product-families.controller'; import { ProductFamiliesService } from './product-families.service'; @Module({ imports: [PrismaModule], controllers: [ProductFamiliesController], - providers: [ProductFamiliesService, FamilyRecomputeService], - exports: [ProductFamiliesService, FamilyRecomputeService], + providers: [ProductFamiliesService, FamilyRecomputeService, OrganizeService], + exports: [ProductFamiliesService, FamilyRecomputeService, OrganizeService], }) export class ProductFamiliesModule {} diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts index 31a82f6..5698bb4 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -3,10 +3,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { ProductFamiliesService } from './product-families.service'; import { FamilyRecomputeService } from './family-recompute.service'; +import { OrganizeService } from './organize.service'; import { PrismaService } from '../prisma/prisma.service'; describe('ProductFamiliesService', () => { let service: ProductFamiliesService; + let organize!: OrganizeService; let prisma: PrismaService; const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; @@ -29,6 +31,11 @@ describe('ProductFamiliesService', () => { providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService], }).compile(); service = moduleRef.get(ProductFamiliesService); + organize = new OrganizeService( + moduleRef.get(PrismaService), + moduleRef.get(FamilyRecomputeService), + service, + ); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); }); @@ -57,6 +64,7 @@ describe('ProductFamiliesService', () => { price: new Prisma.Decimal(25), }, }); + await organize.deriveTagsForOg(a.id); const f1 = (await service.create({ familyName: '测试T恤', familyCode: code, @@ -216,7 +224,7 @@ describe('ProductFamiliesService', () => { }); it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => { - const a = await mkOriginGood(`覆盖${stamp}`, { craftLabel: '单面印花', logisticsLabel: '包邮' }); + const a = await mkOriginGood(`美国(包邮)覆盖测试-OV${stamp}-单面印花`); await prisma.originGoodVariant.create({ data: { originGoodId: a.id, @@ -229,6 +237,7 @@ describe('ProductFamiliesService', () => { price: new Prisma.Decimal(25), }, }); + await organize.deriveTagsForOg(a.id); const f = (await service.create({ familyName: `覆盖族-${stamp}`, originGoodIds: [a.id.toString()], diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts index 83796ba..87bdf47 100644 --- a/apps/api/src/public/public-family-block.spec.ts +++ b/apps/api/src/public/public-family-block.spec.ts @@ -3,6 +3,8 @@ import { Prisma } from '@prisma/client'; import { PublicService } from './public.service'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { ProductFamiliesService } from '../product-families/product-families.service'; +import { OrganizeService } from '../product-families/organize.service'; /** * 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、 @@ -68,7 +70,15 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { familyId = family.id; createdFamilyIds.push(family.id); await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); - await new FamilyRecomputeService(prisma).recomputeFamily(family.id); + // 解析去运行时化:先整理派生标签,再重算(重算只聚合不解析) + const recomputeSvc = new FamilyRecomputeService(prisma); + const organizeSvc = new OrganizeService( + prisma, + recomputeSvc, + new ProductFamiliesService(prisma, recomputeSvc), + ); + await organizeSvc.deriveFamilyTags(family.id); + await recomputeSvc.recomputeFamily(family.id); const good = await prisma.good.create({ data: { diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts index 4532536..bf9ed39 100644 --- a/apps/api/src/sync/sync-family-hooks.spec.ts +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -3,16 +3,20 @@ import { Prisma } from '@prisma/client'; import { SyncService } from './sync.service'; import { SdsClientService } from './sds-client.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { ProductFamiliesService } from '../product-families/product-families.service'; +import { OrganizeService } from '../product-families/organize.service'; import { PrismaService } from '../prisma/prisma.service'; /** - * 同步钩子集成测试:upsertOriginGood 的解析列写入、新链接自动挂族、 + * 同步钩子集成测试(解析去运行时化后): + * upsertOriginGood 纯镜像(不写解析列)、新链接不自动挂族(归族走整理)、 * persistProductDetail 后的族重算入队。 */ describe('SyncService family hooks', () => { let service: SyncService; let prisma: PrismaService; let recompute: FamilyRecomputeService; + let organize: OrganizeService; const stamp = Date.now(); const createdOriginGoodIds: bigint[] = []; const createdFamilyIds: bigint[] = []; @@ -29,12 +33,15 @@ describe('SyncService family hooks', () => { useValue: {}, }, FamilyRecomputeService, + ProductFamiliesService, + OrganizeService, PrismaService, ], }).compile(); service = moduleRef.get(SyncService); prisma = moduleRef.get(PrismaService); recompute = moduleRef.get(FamilyRecomputeService); + organize = moduleRef.get(OrganizeService); await prisma.onModuleInit(); }); @@ -44,7 +51,7 @@ describe('SyncService family hooks', () => { await prisma.$disconnect(); }); - it('upsertOriginGood:写入四个解析列(create 与 update 全量覆盖)', async () => { + it('upsertOriginGood:纯镜像——只存原文,不写解析列', async () => { const sdsId = `hook-${stamp}-parse`; const result1 = await (service as any).upsertOriginGood( sdsProduct(sdsId, '美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'), @@ -53,22 +60,24 @@ describe('SyncService family hooks', () => { expect(result1).toBe('inserted'); const og = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); createdOriginGoodIds.push(og.id); - expect(og.skuCode).toBe('DG206'); - expect(og.logisticsLabel).toBe('包邮'); - expect(og.craftLabel).toBe('单面印花'); - expect(og.warehouseLabel).toBe('美西洛杉矶一仓'); + expect(og.goodName).toBe('美国(包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'); + expect(og.skuCode).toBeNull(); + expect(og.logisticsLabel).toBeNull(); + expect(og.craftLabel).toBeNull(); + expect(og.warehouseLabel).toBeNull(); - // 更新为不带仓库的名称 → 解析列全量覆盖(warehouseLabel 置空) + // 更新名称 → 镜像原文变化;解析列保持为空(由整理回填) await (service as any).upsertOriginGood( sdsProduct(sdsId, '美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'), `cat-${stamp}-parse`, ); const og2 = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: sdsId } }); - expect(og2.logisticsLabel).toBe('不包邮'); - expect(og2.warehouseLabel).toBeNull(); + expect(og2.goodName).toBe('美国(不包邮)240g涤纶休闲短裤-DG206-单面印花'); + expect(og2.skuCode).toBeNull(); + expect(og2.logisticsLabel).toBeNull(); }); - it('新链接自动挂族:按 SDS 分类唯一命中族则挂载并触发重算', async () => { + it('新链接不自动挂族(归族由整理显式完成);族矩阵保持不变', async () => { const sdsCat = `cat-hook-${stamp}`; const cat = await prisma.category.create({ data: { categoryName: `挂族测试分类-${stamp}`, sdsCategoryId: sdsCat }, @@ -78,8 +87,6 @@ describe('SyncService family hooks', () => { sdsGoodId: `hook-${stamp}-seed`, goodName: `自动挂${stamp}(包邮)卫衣-ZZA${stamp}-单面印花`, sdsCategoryId: sdsCat, - craftLabel: '单面印花', - logisticsLabel: '包邮', }, }); createdOriginGoodIds.push(seed.id); @@ -106,11 +113,12 @@ describe('SyncService family hooks', () => { where: { id: seed.id }, data: { familyId: family.id }, }); + await organize.deriveTagsForOg(seed.id); await recompute.recomputeFamily(family.id); const before = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); expect((before.priceMatrix as any).rows).toHaveLength(1); - // 同分类新链接(不同工艺)→ 自动挂进唯一族 + // 同分类新链接(不同工艺)→ 同步后保持无族,等整理/管理员归族 const newSdsId = `hook-${stamp}-new`; await (service as any).upsertOriginGood( sdsProduct(newSdsId, `自动挂${stamp}(不包邮)卫衣-ZZA${stamp}-双面印花-某仓`), @@ -118,10 +126,12 @@ describe('SyncService family hooks', () => { ); const newOg = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: newSdsId } }); createdOriginGoodIds.push(newOg.id); - expect(newOg.familyId).toBe(family.id); + expect(newOg.familyId).toBeNull(); - // 入队的重算已执行(等待异步完成) + // 族矩阵不因新链接同步而变化 await new Promise((r) => setTimeout(r, 200)); + const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } }); + expect((after.priceMatrix as any).rows).toHaveLength(1); await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); }); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 1bd4ee6..934cf2c 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -1,826 +1,766 @@ -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; -import { - SdsClientService, - SdsCategoryTreeNode, - SdsProduct, - SdsProductDetail, -} from './sds-client.service'; -import { normalizeProductDetail } from './sds-product-detail.mapper'; -import { FamilyRecomputeService } from '../product-families/family-recompute.service'; -import { originGroupKey, parseOriginName } from '../product-families/origin-name.parser'; - -export interface CategorySyncResult { - inserted: number; - updated: number; - total: number; - deletedStale: number; -} - -export interface ProductSyncResult { - inserted: number; - updated: number; - total: number; - leafCategories: number; - delisted: number; -} - -/** - * Safety guards so a degenerate/partial SDS response never triggers a - * destructive operation (stale category deletion / mass delist marking). - * Upstream normally returns ~226 categories and ~150 leaf categories with - * hundreds of products — the floors below only trigger on abnormal responses. - */ -export const SYNC_GUARDS = { - MIN_CATEGORY_COUNT: 10, - MIN_CATEGORY_RATIO: 0.5, - MIN_LEAF_CATEGORIES: 10, - MIN_SEEN_GOODS: 50, -} as const; - -/** - * True when the fetched category count is suspiciously small compared to the - * categories already synced from SDS, i.e. the upstream response is likely - * partial/degenerate. In that case stale deletion must be skipped. - */ -export function shouldSkipStaleDeletion(fetched: number, existingSds: number): boolean { - if (existingSds <= 0) return false; - return ( - fetched < SYNC_GUARDS.MIN_CATEGORY_COUNT || - fetched < SYNC_GUARDS.MIN_CATEGORY_RATIO * existingSds - ); -} - -/** - * True only when both the leaf-category count and the number of seen products - * are healthy enough to trust the "not seen upstream => delisted" conclusion. - */ -export function shouldRunDelistDetection(leafCategories: number, seenGoods: number): boolean { - return ( - leafCategories >= SYNC_GUARDS.MIN_LEAF_CATEGORIES && - seenGoods >= SYNC_GUARDS.MIN_SEEN_GOODS - ); -} - -@Injectable() -export class SyncService { - private readonly logger = new Logger(SyncService.name); - private running = { categories: false, products: false, details: false }; - - constructor( - private readonly prisma: PrismaService, - private readonly sds: SdsClientService, - private readonly familyRecompute: FamilyRecomputeService, - ) {} - - /** - * Hourly full sync — runs `syncCategories` first (since product - * sync depends on knowing which leaf categories exist) and then - * `syncProducts`. - */ - @Cron(CronExpression.EVERY_HOUR) - async hourlyCron(): Promise { - try { - await this.syncCategories(); - await this.syncProducts(); - } catch (err) { - this.logger.error('Hourly cron sync failed', err as Error); - } - } - - /** Fire-and-forget wrappers for manual triggers via HTTP. */ - async startCategorySync(): Promise<{ message: string }> { - if (this.running.categories) { - return { message: 'Category sync already in progress' }; - } - void this.syncCategories().catch((err) => - this.logger.error('Category sync failed', err as Error), - ); - return { message: 'Category sync started' }; - } - - async startProductSync(): Promise<{ message: string }> { - if (this.running.products) { - return { message: 'Product sync already in progress' }; - } - void this.syncProducts().catch((err) => - this.logger.error('Product sync failed', err as Error), - ); - return { message: 'Product sync started' }; - } - - /** Refresh all active SDS product details once per day at 03:30. */ - @Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' }) - async dailyProductDetailCron(): Promise { - try { - await this.syncProductDetails(); - } catch (err) { - this.logger.error('Daily product detail sync failed', err as Error); - } - } - - async startProductDetailSync(): Promise<{ message: string }> { - if (this.running.details) { - return { message: 'Product detail sync already in progress' }; - } - void this.syncProductDetails().catch((err) => - this.logger.error('Product detail sync failed', err as Error), - ); - return { message: 'Product detail sync started' }; - } - - /** Check if a sync type is currently running. */ - isRunning(type: 'categories' | 'products' | 'details'): boolean { - return this.running[type]; - } - - async syncCategories(): Promise { - if (this.running.categories) { - throw new Error('Category sync already in progress'); - } - this.running.categories = true; - const log = await this.prisma.syncLog.create({ - data: { type: 'CATEGORIES', status: 'RUNNING' }, - }); - try { - const tree = await this.sds.fetchCategoryTree(); - const flat = this.flattenCategoryTree(tree); - this.logger.log(`Fetched ${flat.length} SDS categories`); - const seenSdsIds = new Set(flat.map((n) => n.sdsId)); - - // Guard: if the upstream tree is suspiciously small vs what we already - // have from SDS, skip stale deletion entirely — a partial response must - // never wipe the category library. - const existingSdsCount = await this.prisma.category.count({ - where: { sdsCategoryId: { not: null } }, - }); - const skipStaleDeletion = shouldSkipStaleDeletion(flat.length, existingSdsCount); - if (skipStaleDeletion) { - this.logger.warn( - `Skipping stale category deletion: fetched=${flat.length} existingSds=${existingSdsCount} ` + - `(below guard thresholds)`, - ); - } - - // Single transaction: upsert + wire parents + delete stale. - // SDS tree is the source of truth — anything not in the response gets deleted - // (unless the response looks degenerate, see guard above). - const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => { - let ins = 0; - let upd = 0; - - // 1. Upsert all SDS categories - for (const node of flat) { - const existing = await tx.category.findUnique({ - where: { sdsCategoryId: node.sdsId }, - }); - if (!existing) { - await tx.category.create({ - data: { - sdsCategoryId: node.sdsId, - categoryName: node.name, - categoryIcon: node.icon ?? null, - }, - }); - ins++; - } else { - await tx.category.update({ - where: { id: existing.id }, - data: { - categoryName: node.name, - categoryIcon: node.icon ?? null, - }, - }); - upd++; - } - } - - // 2. Wire parent-child relationships - for (const node of flat) { - if (!node.parentSdsId) continue; - const child = await tx.category.findUnique({ - where: { sdsCategoryId: node.sdsId }, - }); - const parent = await tx.category.findUnique({ - where: { sdsCategoryId: node.parentSdsId }, - }); - if (child && parent && child.parentCategoryId !== parent.id) { - await tx.category.update({ - where: { id: child.id }, - data: { parentCategoryId: parent.id }, - }); - } - } - - // 3. Delete stale categories (in DB but not in SDS response) - // Detach parent links first, then delete leaf-first to respect FK constraints. - // Skipped entirely when the response looks degenerate (see guard above). - let deletedStale = 0; - if (!skipStaleDeletion) { - const staleCats = await tx.category.findMany({ - where: { sdsCategoryId: { notIn: [...seenSdsIds] } }, - select: { id: true }, - }); - const staleIds = staleCats.map((c) => c.id); - - // Protect categories that have configured goods — onDelete: Restrict - const goodsInStale = await tx.good.groupBy({ - by: ['categoryId'], - where: { categoryId: { in: staleIds } }, - }); - const protectedIds = new Set(goodsInStale.map((g) => g.categoryId)); - const deletableIds = staleIds.filter((id) => !protectedIds.has(id)); - deletedStale = deletableIds.length; - - // Detach all deletable categories from their parents - if (deletableIds.length > 0) { - await tx.category.updateMany({ - where: { id: { in: deletableIds } }, - data: { parentCategoryId: null }, - }); - // Also detach any non-deletable children pointing to deletable parents - await tx.category.updateMany({ - where: { parentCategoryId: { in: deletableIds } }, - data: { parentCategoryId: null }, - }); - // Delete leaf-first (repeatedly remove nodes with no children) - let remaining = [...deletableIds]; - while (remaining.length > 0) { - const withChildren = await tx.category.findMany({ - where: { parentCategoryId: { in: remaining } }, - select: { parentCategoryId: true }, - distinct: ['parentCategoryId'], - }); - const hasChildSet = new Set( - withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()), - ); - const leaves = remaining.filter((id) => !hasChildSet.has(id.toString())); - if (leaves.length === 0) break; // safety: circular dependency - await tx.category.deleteMany({ where: { id: { in: leaves } } }); - remaining = remaining.filter((id) => !leaves.some((l) => l === id)); - } - } - } - - return { inserted: ins, updated: upd, deletedStale }; - }); - - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - status: 'SUCCESS', - finishedAt: new Date(), - message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`, - }, - }); - return { inserted, updated, total: flat.length, deletedStale }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - status: 'FAILED', - finishedAt: new Date(), - message, - }, - }); - throw err; - } finally { - this.running.categories = false; - } - } - - async syncProducts(): Promise { - if (this.running.products) { - throw new Error('Product sync already in progress'); - } - this.running.products = true; - const log = await this.prisma.syncLog.create({ - data: { type: 'PRODUCTS', status: 'RUNNING' }, - }); - try { - // Identify leaf categories — those with no children. - const all = await this.prisma.category.findMany({ - select: { id: true, sdsCategoryId: true }, - }); - const parents = await this.prisma.category.findMany({ - where: { parent: { isNot: null } }, - select: { parentCategoryId: true }, - }); - const parentIds = new Set(parents.map((p) => p.parentCategoryId!)); - const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId); - - let inserted = 0; - let updated = 0; - let total = 0; - const seenSdsGoodIds = new Set(); - - for (const leaf of leafRows) { - const sdsCategoryId = leaf.sdsCategoryId!; - let page = 1; - // eslint-disable-next-line no-constant-condition - while (true) { - const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50); - const products = resp.items ?? resp.content ?? []; - if (products.length === 0) break; - for (const product of products) { - seenSdsGoodIds.add(String(product.id)); - const upserted = await this.upsertOriginGood(product, sdsCategoryId); - if (upserted === 'inserted') inserted++; - else updated++; - total++; - } - if (products.length < 50) break; - page++; - if (page > 200) { - // Safety net — at most 10k products per category. - this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`); - break; - } - } - } - - // Detect delisted products: mark origin goods not seen in upstream as delisted, - // and re-activate any previously delisted goods that reappeared. - // Guard: only trust this conclusion when the sync covered a healthy number of - // leaf categories and saw a healthy number of products — otherwise a partial - // sync must never mass-delist the product library. - let delistedCount = 0; - let reactivatedCount = 0; - const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size); - if (runDelist) { - const delistedResult = await this.prisma.originGood.updateMany({ - where: { - source: 'SDS', - sdsGoodId: { notIn: [...seenSdsGoodIds] }, - delisted: false, - }, - data: { delisted: true }, - }); - const reactivatedResult = await this.prisma.originGood.updateMany({ - where: { - source: 'SDS', - sdsGoodId: { in: [...seenSdsGoodIds] }, - delisted: true, - }, - data: { delisted: false }, - }); - delistedCount = delistedResult.count; - reactivatedCount = reactivatedResult.count; - } else { - this.logger.warn( - `Skipping delist detection: leafCategories=${leafRows.length} seenGoods=${seenSdsGoodIds.size} ` + - `(below guard thresholds)`, - ); - } - - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - status: 'SUCCESS', - finishedAt: new Date(), - message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`, - }, - }); - return { - inserted, - updated, - total, - leafCategories: leafRows.length, - delisted: delistedCount, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - status: 'FAILED', - finishedAt: new Date(), - message, - }, - }); - throw err; - } finally { - this.running.products = false; - } - } - - async getStatus(limit = 20) { - return this.prisma.syncLog.findMany({ - orderBy: { startedAt: 'desc' }, - take: limit, - }); - } - - async syncProductDetails(): Promise<{ - total: number; - synced: number; - failed: number; - }> { - if (this.running.details) { - throw new Error('Product detail sync already in progress'); - } - this.running.details = true; - const log = await this.prisma.syncLog.create({ - data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' }, - }); - try { - const result = await this.syncAllProductDetails(async (progress) => { - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`, - }, - }); - }); - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { - status: 'SUCCESS', - finishedAt: new Date(), - message: `total=${result.total} synced=${result.synced} failed=${result.failed}`, - }, - }); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await this.prisma.syncLog.update({ - where: { id: log.id }, - data: { status: 'FAILED', finishedAt: new Date(), message }, - }); - throw error; - } finally { - this.running.details = false; - } - } - - async syncOneProductDetail(goodId: string): Promise<{ - goodId: string; - variants: number; - detailSyncedAt: string; - }> { - const originGood = await this.prisma.originGood.findUnique({ - where: { sdsGoodId: goodId }, - select: { id: true, source: true }, - }); - if (!originGood) { - throw new NotFoundException(`SDS product ${goodId} not found locally`); - } - if (originGood.source !== 'SDS') { - throw new BadRequestException('自定义商品不支持从 SDS 同步详情'); - } - const upstream = await this.sds.fetchProductDetail(goodId); - const normalized = normalizeProductDetail(upstream); - await this.persistProductDetail(originGood.id, upstream); - return { - goodId, - variants: normalized.variants.length, - detailSyncedAt: new Date().toISOString(), - }; - } - - queueProductDetailSync(goodId: string): void { - void this.syncOneProductDetail(goodId).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`); - }); - } - - async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> { - const result = await this.syncMatchingProductDetails({ - delisted: false, - source: 'SDS', - goods: { some: {} }, - }); - return { synced: result.synced, failed: result.failed }; - } - - async syncAllProductDetails( - onProgress?: (progress: { - processed: number; - total: number; - synced: number; - failed: number; - }) => Promise, - ): Promise<{ total: number; synced: number; failed: number }> { - return this.syncMatchingProductDetails( - { delisted: false, source: 'SDS' }, - onProgress, - 2, - ); - } - - private async syncMatchingProductDetails( - where: Prisma.OriginGoodWhereInput, - onProgress?: (progress: { - processed: number; - total: number; - synced: number; - failed: number; - }) => Promise, - attempts = 1, - ): Promise<{ total: number; synced: number; failed: number }> { - const originGoods = await this.prisma.originGood.findMany({ - where, - select: { id: true, sdsGoodId: true }, - orderBy: { id: 'asc' }, - }); - let synced = 0; - let failed = 0; - let processed = 0; - for (const originGood of originGoods) { - let lastError: unknown; - let succeeded = false; - for (let attempt = 1; attempt <= attempts; attempt++) { - try { - const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); - await this.persistProductDetail(originGood.id, upstream); - synced++; - succeeded = true; - break; - } catch (error) { - lastError = error; - } - } - if (!succeeded) { - failed++; - const message = lastError instanceof Error ? lastError.message : String(lastError); - this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`); - } - processed++; - if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) { - await onProgress({ processed, total: originGoods.length, synced, failed }); - } - } - return { total: originGoods.length, synced, failed }; - } - - async importProductDetail(upstream: SdsProductDetail): Promise<{ - goodId: string; - variants: number; - sizeRows: number; - packageRows: number; - configuredGoods: number; - }> { - const goodId = String(upstream.id); - const normalized = normalizeProductDetail(upstream); - const originGood = await this.prisma.originGood.upsert({ - where: { sdsGoodId: goodId }, - create: { - sdsGoodId: goodId, - goodName: String(upstream.name ?? goodId), - goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null, - goodPrice: - upstream.min_price === undefined || upstream.min_price === null - ? null - : new Prisma.Decimal(Number(upstream.min_price)), - }, - update: { - goodName: upstream.name ? String(upstream.name) : undefined, - goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined, - goodPrice: - upstream.min_price === undefined || upstream.min_price === null - ? undefined - : new Prisma.Decimal(Number(upstream.min_price)), - }, - }); - await this.persistProductDetail(originGood.id, upstream); - const configuredGoods = await this.prisma.good.count({ - where: { originGoodId: originGood.id }, - }); - const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null; - const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null; - return { - goodId, - variants: normalized.variants.length, - sizeRows: sizeChart?.rows?.length ?? 0, - packageRows: packageSpecs?.rows?.length ?? 0, - configuredGoods, - }; - } - - private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise { - const normalized = normalizeProductDetail(upstream); - const { variants, ...detail } = normalized; - await this.prisma.$transaction(async (tx) => { - const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull; - // Backfill the origin good's price from upstream min_price when present - if (upstream.min_price !== undefined && upstream.min_price !== null) { - await tx.originGood.update({ - where: { id: originGoodId }, - data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) }, - }); - } - await tx.originGoodDetail.upsert({ - where: { originGoodId }, - create: { - originGoodId, - ...detail, - sizeChart: json(detail.sizeChart), - packageSpecs: json(detail.packageSpecs), - options: json(detail.options), - media: json(detail.media), - }, - update: { - ...detail, - sizeChart: json(detail.sizeChart), - packageSpecs: json(detail.packageSpecs), - options: json(detail.options), - media: json(detail.media), - syncedAt: new Date(), - }, - }); - const seenVariantIds: string[] = []; - for (const variant of variants) { - seenVariantIds.push(variant.sdsVariantId); - const { designData, ...data } = variant; - await tx.originGoodVariant.upsert({ - where: { - originGoodId_sdsVariantId: { - originGoodId, - sdsVariantId: variant.sdsVariantId, - }, - }, - create: { originGoodId, ...data, designData: json(designData) }, - update: { ...data, designData: json(designData) }, - }); - } - await tx.originGoodVariant.deleteMany({ - where: { - originGoodId, - ...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}), - }, - }); - }); - // 族成员的详情/变体变化 → 异步重算该族(进程内去重) - await this.maybeEnqueueFamilyRecompute(originGoodId); - } - - /** 详情同步后的族重算钩子:链接有族归属才入队 */ - private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise { - const og = await this.prisma.originGood.findUnique({ - where: { id: originGoodId }, - select: { familyId: true }, - }); - if (og?.familyId) this.familyRecompute.enqueue(og.familyId); - } - - /** - * 新链接自动挂族:优先按 SDS 分类(=产品模型)匹配已有族的成员; - * 无分类时回退名称 3 段键。恰好命中唯一族才挂载(多族/零族留给管理员裁决)。 - * 锁定族(autoManaged=false)不吸收新成员,只置 stale 提示。 - */ - private async tryAutoAttachToFamily(originGoodId: bigint, goodName: string): Promise { - const self = await this.prisma.originGood.findUnique({ - where: { id: originGoodId }, - select: { sdsCategoryId: true }, - }); - let familyIds: Set; - if (self?.sdsCategoryId) { - const siblings = await this.prisma.originGood.findMany({ - where: { sdsCategoryId: self.sdsCategoryId, familyId: { not: null } }, - select: { familyId: true }, - distinct: ['familyId'], - }); - familyIds = new Set(siblings.map((s) => s.familyId!.toString())); - } else { - const key = originGroupKey(goodName); - if (!key) return; - const candidates = await this.prisma.originGood.findMany({ - where: { familyId: { not: null }, goodName: { startsWith: key } }, - select: { familyId: true, goodName: true }, - }); - familyIds = new Set( - candidates - .filter((c) => originGroupKey(c.goodName) === key && c.familyId !== null) - .map((c) => c.familyId!.toString()), - ); - } - if (familyIds.size !== 1) return; - const familyId = BigInt([...familyIds][0]); - const family = await this.prisma.productFamily.findUnique({ - where: { id: familyId }, - select: { autoManaged: true }, - }); - if (!family) return; - if (family.autoManaged) { - await this.prisma.originGood.update({ - where: { id: originGoodId }, - data: { familyId }, - }); - // Good 的族随主链接联动 - await this.prisma.good.updateMany({ - where: { originGoodId }, - data: { familyId }, - }); - this.familyRecompute.enqueue(familyId); - } else { - await this.prisma.productFamily.update({ - where: { id: familyId }, - data: { stale: true }, - }); - } - } - - /** - * Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`. - */ - flattenCategoryTree( - nodes: SdsCategoryTreeNode[], - parentSdsId?: string, - ): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> { - const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = []; - const walk = (node: SdsCategoryTreeNode, parent?: string) => { - const sdsId = String(node.id); - if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return; - out.push({ - sdsId, - parentSdsId: parent, - name: String(node.name ?? node.title ?? sdsId), - icon: node.icon ? String(node.icon) : undefined, - }); - if (Array.isArray(node.children)) { - for (const child of node.children) walk(child, sdsId); - } - }; - for (const root of nodes) walk(root, parentSdsId); - return out; - } - - private async upsertOriginGood( - product: SdsProduct, - sdsCategoryId: string, - ): Promise<'inserted' | 'updated'> { - const sdsGoodId = String(product.id); - const existing = await this.prisma.originGood.findUnique({ - where: { sdsGoodId }, - }); - const goodName = String(product.name ?? product.title ?? sdsGoodId); - const goodImage = product.psd_img_url - ? String(product.psd_img_url) - : product.blankDesignUrl - ? String(product.blankDesignUrl) - : product.thumbImgUrl - ? String(product.thumbImgUrl) - : product.show_img - ? String(product.show_img) - : product.img_url - ? String(product.img_url) - : product.pic - ? String(product.pic) - : product.image - ? String(product.image) - : null; - const priceValue = product.currentPrice ?? product.price; - let goodPrice: Prisma.Decimal | null = null; - if (priceValue !== undefined && priceValue !== null) { - const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue; - if (Number.isFinite(n)) { - goodPrice = new Prisma.Decimal(n); - } - } - // 链接名结构化解析列(镜像纯度:全量覆盖,含空值) - const parsed = parseOriginName(goodName); - const parsedData = { - skuCode: parsed.skuCode, - logisticsLabel: parsed.logisticsLabel, - craftLabel: parsed.craftLabel, - warehouseLabel: parsed.warehouseLabel, - }; - const data: Prisma.OriginGoodUncheckedUpdateInput = { - sdsCategoryId, - goodName, - goodImage, - goodPrice, - ...parsedData, - }; - - if (!existing) { - const created = await this.prisma.originGood.create({ - data: { - sdsGoodId, - sdsCategoryId, - goodName, - goodImage, - goodPrice, - ...parsedData, - }, - select: { id: true }, - }); - await this.tryAutoAttachToFamily(created.id, goodName); - return 'inserted'; - } - await this.prisma.originGood.update({ - where: { id: existing.id }, - data, - }); - return 'updated'; - } -} +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + SdsClientService, + SdsCategoryTreeNode, + SdsProduct, + SdsProductDetail, +} from './sds-client.service'; +import { normalizeProductDetail } from './sds-product-detail.mapper'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; + + +export interface CategorySyncResult { + inserted: number; + updated: number; + total: number; + deletedStale: number; +} + +export interface ProductSyncResult { + inserted: number; + updated: number; + total: number; + leafCategories: number; + delisted: number; +} + +/** + * Safety guards so a degenerate/partial SDS response never triggers a + * destructive operation (stale category deletion / mass delist marking). + * Upstream normally returns ~226 categories and ~150 leaf categories with + * hundreds of products — the floors below only trigger on abnormal responses. + */ +export const SYNC_GUARDS = { + MIN_CATEGORY_COUNT: 10, + MIN_CATEGORY_RATIO: 0.5, + MIN_LEAF_CATEGORIES: 10, + MIN_SEEN_GOODS: 50, +} as const; + +/** + * True when the fetched category count is suspiciously small compared to the + * categories already synced from SDS, i.e. the upstream response is likely + * partial/degenerate. In that case stale deletion must be skipped. + */ +export function shouldSkipStaleDeletion(fetched: number, existingSds: number): boolean { + if (existingSds <= 0) return false; + return ( + fetched < SYNC_GUARDS.MIN_CATEGORY_COUNT || + fetched < SYNC_GUARDS.MIN_CATEGORY_RATIO * existingSds + ); +} + +/** + * True only when both the leaf-category count and the number of seen products + * are healthy enough to trust the "not seen upstream => delisted" conclusion. + */ +export function shouldRunDelistDetection(leafCategories: number, seenGoods: number): boolean { + return ( + leafCategories >= SYNC_GUARDS.MIN_LEAF_CATEGORIES && + seenGoods >= SYNC_GUARDS.MIN_SEEN_GOODS + ); +} + +@Injectable() +export class SyncService { + private readonly logger = new Logger(SyncService.name); + private running = { categories: false, products: false, details: false }; + + constructor( + private readonly prisma: PrismaService, + private readonly sds: SdsClientService, + private readonly familyRecompute: FamilyRecomputeService, + ) {} + + /** + * Hourly full sync — runs `syncCategories` first (since product + * sync depends on knowing which leaf categories exist) and then + * `syncProducts`. + */ + @Cron(CronExpression.EVERY_HOUR) + async hourlyCron(): Promise { + try { + await this.syncCategories(); + await this.syncProducts(); + } catch (err) { + this.logger.error('Hourly cron sync failed', err as Error); + } + } + + /** Fire-and-forget wrappers for manual triggers via HTTP. */ + async startCategorySync(): Promise<{ message: string }> { + if (this.running.categories) { + return { message: 'Category sync already in progress' }; + } + void this.syncCategories().catch((err) => + this.logger.error('Category sync failed', err as Error), + ); + return { message: 'Category sync started' }; + } + + async startProductSync(): Promise<{ message: string }> { + if (this.running.products) { + return { message: 'Product sync already in progress' }; + } + void this.syncProducts().catch((err) => + this.logger.error('Product sync failed', err as Error), + ); + return { message: 'Product sync started' }; + } + + /** Refresh all active SDS product details once per day at 03:30. */ + @Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' }) + async dailyProductDetailCron(): Promise { + try { + await this.syncProductDetails(); + } catch (err) { + this.logger.error('Daily product detail sync failed', err as Error); + } + } + + async startProductDetailSync(): Promise<{ message: string }> { + if (this.running.details) { + return { message: 'Product detail sync already in progress' }; + } + void this.syncProductDetails().catch((err) => + this.logger.error('Product detail sync failed', err as Error), + ); + return { message: 'Product detail sync started' }; + } + + /** Check if a sync type is currently running. */ + isRunning(type: 'categories' | 'products' | 'details'): boolean { + return this.running[type]; + } + + async syncCategories(): Promise { + if (this.running.categories) { + throw new Error('Category sync already in progress'); + } + this.running.categories = true; + const log = await this.prisma.syncLog.create({ + data: { type: 'CATEGORIES', status: 'RUNNING' }, + }); + try { + const tree = await this.sds.fetchCategoryTree(); + const flat = this.flattenCategoryTree(tree); + this.logger.log(`Fetched ${flat.length} SDS categories`); + const seenSdsIds = new Set(flat.map((n) => n.sdsId)); + + // Guard: if the upstream tree is suspiciously small vs what we already + // have from SDS, skip stale deletion entirely — a partial response must + // never wipe the category library. + const existingSdsCount = await this.prisma.category.count({ + where: { sdsCategoryId: { not: null } }, + }); + const skipStaleDeletion = shouldSkipStaleDeletion(flat.length, existingSdsCount); + if (skipStaleDeletion) { + this.logger.warn( + `Skipping stale category deletion: fetched=${flat.length} existingSds=${existingSdsCount} ` + + `(below guard thresholds)`, + ); + } + + // Single transaction: upsert + wire parents + delete stale. + // SDS tree is the source of truth — anything not in the response gets deleted + // (unless the response looks degenerate, see guard above). + const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => { + let ins = 0; + let upd = 0; + + // 1. Upsert all SDS categories + for (const node of flat) { + const existing = await tx.category.findUnique({ + where: { sdsCategoryId: node.sdsId }, + }); + if (!existing) { + await tx.category.create({ + data: { + sdsCategoryId: node.sdsId, + categoryName: node.name, + categoryIcon: node.icon ?? null, + }, + }); + ins++; + } else { + await tx.category.update({ + where: { id: existing.id }, + data: { + categoryName: node.name, + categoryIcon: node.icon ?? null, + }, + }); + upd++; + } + } + + // 2. Wire parent-child relationships + for (const node of flat) { + if (!node.parentSdsId) continue; + const child = await tx.category.findUnique({ + where: { sdsCategoryId: node.sdsId }, + }); + const parent = await tx.category.findUnique({ + where: { sdsCategoryId: node.parentSdsId }, + }); + if (child && parent && child.parentCategoryId !== parent.id) { + await tx.category.update({ + where: { id: child.id }, + data: { parentCategoryId: parent.id }, + }); + } + } + + // 3. Delete stale categories (in DB but not in SDS response) + // Detach parent links first, then delete leaf-first to respect FK constraints. + // Skipped entirely when the response looks degenerate (see guard above). + let deletedStale = 0; + if (!skipStaleDeletion) { + const staleCats = await tx.category.findMany({ + where: { sdsCategoryId: { notIn: [...seenSdsIds] } }, + select: { id: true }, + }); + const staleIds = staleCats.map((c) => c.id); + + // Protect categories that have configured goods — onDelete: Restrict + const goodsInStale = await tx.good.groupBy({ + by: ['categoryId'], + where: { categoryId: { in: staleIds } }, + }); + const protectedIds = new Set(goodsInStale.map((g) => g.categoryId)); + const deletableIds = staleIds.filter((id) => !protectedIds.has(id)); + deletedStale = deletableIds.length; + + // Detach all deletable categories from their parents + if (deletableIds.length > 0) { + await tx.category.updateMany({ + where: { id: { in: deletableIds } }, + data: { parentCategoryId: null }, + }); + // Also detach any non-deletable children pointing to deletable parents + await tx.category.updateMany({ + where: { parentCategoryId: { in: deletableIds } }, + data: { parentCategoryId: null }, + }); + // Delete leaf-first (repeatedly remove nodes with no children) + let remaining = [...deletableIds]; + while (remaining.length > 0) { + const withChildren = await tx.category.findMany({ + where: { parentCategoryId: { in: remaining } }, + select: { parentCategoryId: true }, + distinct: ['parentCategoryId'], + }); + const hasChildSet = new Set( + withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()), + ); + const leaves = remaining.filter((id) => !hasChildSet.has(id.toString())); + if (leaves.length === 0) break; // safety: circular dependency + await tx.category.deleteMany({ where: { id: { in: leaves } } }); + remaining = remaining.filter((id) => !leaves.some((l) => l === id)); + } + } + } + + return { inserted: ins, updated: upd, deletedStale }; + }); + + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + status: 'SUCCESS', + finishedAt: new Date(), + message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`, + }, + }); + return { inserted, updated, total: flat.length, deletedStale }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + status: 'FAILED', + finishedAt: new Date(), + message, + }, + }); + throw err; + } finally { + this.running.categories = false; + } + } + + async syncProducts(): Promise { + if (this.running.products) { + throw new Error('Product sync already in progress'); + } + this.running.products = true; + const log = await this.prisma.syncLog.create({ + data: { type: 'PRODUCTS', status: 'RUNNING' }, + }); + try { + // Identify leaf categories — those with no children. + const all = await this.prisma.category.findMany({ + select: { id: true, sdsCategoryId: true }, + }); + const parents = await this.prisma.category.findMany({ + where: { parent: { isNot: null } }, + select: { parentCategoryId: true }, + }); + const parentIds = new Set(parents.map((p) => p.parentCategoryId!)); + const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId); + + let inserted = 0; + let updated = 0; + let total = 0; + const seenSdsGoodIds = new Set(); + + for (const leaf of leafRows) { + const sdsCategoryId = leaf.sdsCategoryId!; + let page = 1; + // eslint-disable-next-line no-constant-condition + while (true) { + const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50); + const products = resp.items ?? resp.content ?? []; + if (products.length === 0) break; + for (const product of products) { + seenSdsGoodIds.add(String(product.id)); + const upserted = await this.upsertOriginGood(product, sdsCategoryId); + if (upserted === 'inserted') inserted++; + else updated++; + total++; + } + if (products.length < 50) break; + page++; + if (page > 200) { + // Safety net — at most 10k products per category. + this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`); + break; + } + } + } + + // Detect delisted products: mark origin goods not seen in upstream as delisted, + // and re-activate any previously delisted goods that reappeared. + // Guard: only trust this conclusion when the sync covered a healthy number of + // leaf categories and saw a healthy number of products — otherwise a partial + // sync must never mass-delist the product library. + let delistedCount = 0; + let reactivatedCount = 0; + const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size); + if (runDelist) { + const delistedResult = await this.prisma.originGood.updateMany({ + where: { + source: 'SDS', + sdsGoodId: { notIn: [...seenSdsGoodIds] }, + delisted: false, + }, + data: { delisted: true }, + }); + const reactivatedResult = await this.prisma.originGood.updateMany({ + where: { + source: 'SDS', + sdsGoodId: { in: [...seenSdsGoodIds] }, + delisted: true, + }, + data: { delisted: false }, + }); + delistedCount = delistedResult.count; + reactivatedCount = reactivatedResult.count; + } else { + this.logger.warn( + `Skipping delist detection: leafCategories=${leafRows.length} seenGoods=${seenSdsGoodIds.size} ` + + `(below guard thresholds)`, + ); + } + + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + status: 'SUCCESS', + finishedAt: new Date(), + message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`, + }, + }); + return { + inserted, + updated, + total, + leafCategories: leafRows.length, + delisted: delistedCount, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + status: 'FAILED', + finishedAt: new Date(), + message, + }, + }); + throw err; + } finally { + this.running.products = false; + } + } + + async getStatus(limit = 20) { + return this.prisma.syncLog.findMany({ + orderBy: { startedAt: 'desc' }, + take: limit, + }); + } + + async syncProductDetails(): Promise<{ + total: number; + synced: number; + failed: number; + }> { + if (this.running.details) { + throw new Error('Product detail sync already in progress'); + } + this.running.details = true; + const log = await this.prisma.syncLog.create({ + data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' }, + }); + try { + const result = await this.syncAllProductDetails(async (progress) => { + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`, + }, + }); + }); + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + status: 'SUCCESS', + finishedAt: new Date(), + message: `total=${result.total} synced=${result.synced} failed=${result.failed}`, + }, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { status: 'FAILED', finishedAt: new Date(), message }, + }); + throw error; + } finally { + this.running.details = false; + } + } + + async syncOneProductDetail(goodId: string): Promise<{ + goodId: string; + variants: number; + detailSyncedAt: string; + }> { + const originGood = await this.prisma.originGood.findUnique({ + where: { sdsGoodId: goodId }, + select: { id: true, source: true }, + }); + if (!originGood) { + throw new NotFoundException(`SDS product ${goodId} not found locally`); + } + if (originGood.source !== 'SDS') { + throw new BadRequestException('自定义商品不支持从 SDS 同步详情'); + } + const upstream = await this.sds.fetchProductDetail(goodId); + const normalized = normalizeProductDetail(upstream); + await this.persistProductDetail(originGood.id, upstream); + return { + goodId, + variants: normalized.variants.length, + detailSyncedAt: new Date().toISOString(), + }; + } + + queueProductDetailSync(goodId: string): void { + void this.syncOneProductDetail(goodId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`); + }); + } + + async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> { + const result = await this.syncMatchingProductDetails({ + delisted: false, + source: 'SDS', + goods: { some: {} }, + }); + return { synced: result.synced, failed: result.failed }; + } + + async syncAllProductDetails( + onProgress?: (progress: { + processed: number; + total: number; + synced: number; + failed: number; + }) => Promise, + ): Promise<{ total: number; synced: number; failed: number }> { + return this.syncMatchingProductDetails( + { delisted: false, source: 'SDS' }, + onProgress, + 2, + ); + } + + private async syncMatchingProductDetails( + where: Prisma.OriginGoodWhereInput, + onProgress?: (progress: { + processed: number; + total: number; + synced: number; + failed: number; + }) => Promise, + attempts = 1, + ): Promise<{ total: number; synced: number; failed: number }> { + const originGoods = await this.prisma.originGood.findMany({ + where, + select: { id: true, sdsGoodId: true }, + orderBy: { id: 'asc' }, + }); + let synced = 0; + let failed = 0; + let processed = 0; + for (const originGood of originGoods) { + let lastError: unknown; + let succeeded = false; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); + await this.persistProductDetail(originGood.id, upstream); + synced++; + succeeded = true; + break; + } catch (error) { + lastError = error; + } + } + if (!succeeded) { + failed++; + const message = lastError instanceof Error ? lastError.message : String(lastError); + this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`); + } + processed++; + if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) { + await onProgress({ processed, total: originGoods.length, synced, failed }); + } + } + return { total: originGoods.length, synced, failed }; + } + + async importProductDetail(upstream: SdsProductDetail): Promise<{ + goodId: string; + variants: number; + sizeRows: number; + packageRows: number; + configuredGoods: number; + }> { + const goodId = String(upstream.id); + const normalized = normalizeProductDetail(upstream); + const originGood = await this.prisma.originGood.upsert({ + where: { sdsGoodId: goodId }, + create: { + sdsGoodId: goodId, + goodName: String(upstream.name ?? goodId), + goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null, + goodPrice: + upstream.min_price === undefined || upstream.min_price === null + ? null + : new Prisma.Decimal(Number(upstream.min_price)), + }, + update: { + goodName: upstream.name ? String(upstream.name) : undefined, + goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined, + goodPrice: + upstream.min_price === undefined || upstream.min_price === null + ? undefined + : new Prisma.Decimal(Number(upstream.min_price)), + }, + }); + await this.persistProductDetail(originGood.id, upstream); + const configuredGoods = await this.prisma.good.count({ + where: { originGoodId: originGood.id }, + }); + const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null; + const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null; + return { + goodId, + variants: normalized.variants.length, + sizeRows: sizeChart?.rows?.length ?? 0, + packageRows: packageSpecs?.rows?.length ?? 0, + configuredGoods, + }; + } + + private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise { + const normalized = normalizeProductDetail(upstream); + const { variants, ...detail } = normalized; + await this.prisma.$transaction(async (tx) => { + const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull; + // Backfill the origin good's price from upstream min_price when present + if (upstream.min_price !== undefined && upstream.min_price !== null) { + await tx.originGood.update({ + where: { id: originGoodId }, + data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) }, + }); + } + await tx.originGoodDetail.upsert({ + where: { originGoodId }, + create: { + originGoodId, + ...detail, + sizeChart: json(detail.sizeChart), + packageSpecs: json(detail.packageSpecs), + options: json(detail.options), + media: json(detail.media), + }, + update: { + ...detail, + sizeChart: json(detail.sizeChart), + packageSpecs: json(detail.packageSpecs), + options: json(detail.options), + media: json(detail.media), + syncedAt: new Date(), + }, + }); + const seenVariantIds: string[] = []; + for (const variant of variants) { + seenVariantIds.push(variant.sdsVariantId); + const { designData, ...data } = variant; + await tx.originGoodVariant.upsert({ + where: { + originGoodId_sdsVariantId: { + originGoodId, + sdsVariantId: variant.sdsVariantId, + }, + }, + create: { originGoodId, ...data, designData: json(designData) }, + update: { ...data, designData: json(designData) }, + }); + } + await tx.originGoodVariant.deleteMany({ + where: { + originGoodId, + ...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}), + }, + }); + }); + // 族成员的详情/变体变化 → 异步重算该族(进程内去重) + await this.maybeEnqueueFamilyRecompute(originGoodId); + } + + /** 详情同步后的族重算钩子:链接有族归属才入队 */ + private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise { + const og = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { familyId: true }, + }); + if (og?.familyId) this.familyRecompute.enqueue(og.familyId); + } + + /** + * 新链接自动挂族已移除(解析去运行时化):归族由整理(OrganizeService)显式完成。 + * 详见 plans/refactor/organize-script-refactor.md。 + */ + + /** + * Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`. + */ + flattenCategoryTree( + nodes: SdsCategoryTreeNode[], + parentSdsId?: string, + ): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> { + const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = []; + const walk = (node: SdsCategoryTreeNode, parent?: string) => { + const sdsId = String(node.id); + if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return; + out.push({ + sdsId, + parentSdsId: parent, + name: String(node.name ?? node.title ?? sdsId), + icon: node.icon ? String(node.icon) : undefined, + }); + if (Array.isArray(node.children)) { + for (const child of node.children) walk(child, sdsId); + } + }; + for (const root of nodes) walk(root, parentSdsId); + return out; + } + + private async upsertOriginGood( + product: SdsProduct, + sdsCategoryId: string, + ): Promise<'inserted' | 'updated'> { + const sdsGoodId = String(product.id); + const existing = await this.prisma.originGood.findUnique({ + where: { sdsGoodId }, + }); + const goodName = String(product.name ?? product.title ?? sdsGoodId); + const goodImage = product.psd_img_url + ? String(product.psd_img_url) + : product.blankDesignUrl + ? String(product.blankDesignUrl) + : product.thumbImgUrl + ? String(product.thumbImgUrl) + : product.show_img + ? String(product.show_img) + : product.img_url + ? String(product.img_url) + : product.pic + ? String(product.pic) + : product.image + ? String(product.image) + : null; + const priceValue = product.currentPrice ?? product.price; + let goodPrice: Prisma.Decimal | null = null; + if (priceValue !== undefined && priceValue !== null) { + const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue; + if (Number.isFinite(n)) { + goodPrice = new Prisma.Decimal(n); + } + } + // 纯镜像:只存 SDS 原文(名称/图/价/分类 ID)。 + // 结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)与自动归族 + // 由整理(OrganizeService,显式人工触发)负责,同步不解析、不推断。 + const data: Prisma.OriginGoodUncheckedUpdateInput = { + sdsCategoryId, + goodName, + goodImage, + goodPrice, + }; + + if (!existing) { + await this.prisma.originGood.create({ + data: { + sdsGoodId, + sdsCategoryId, + goodName, + goodImage, + goodPrice, + }, + select: { id: true }, + }); + return 'inserted'; + } + await this.prisma.originGood.update({ + where: { id: existing.id }, + data, + }); + return 'updated'; + } +} diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 251d880..0a711a9 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -91,15 +91,25 @@ curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \ "variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}' ``` -**同步联动**:商品同步落库时刷新解析列;新链接与已有族**同分类唯一命中**时自动挂族 -(多族/零族留给管理员;锁定族只置 `stale`);详情同步提交后异步重算受影响族 -(进程内去重、幂等)。回填/修复脚本: +**三层架构(解析去运行时化)**: + +1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列; +2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签 + (未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族 → 全量重算。入口二选一: ```bash -pnpm --filter @inkreach/api backfill:product-families -# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑) +pnpm --filter @inkreach/api organize # CLI +curl -X POST /product-families/organize -H "$AUTH" # 后台「整理」按钮(同一逻辑) ``` +3. **重算 = 自动的结构化聚合**:归族/成员变化/详情同步/标签调整自动触发;输入只有 + 成员变体 + 链接标签 + 覆盖价,**不解析名称**——上游改名永远不会倒灌已派生的标签与矩阵; + 未整理(无标签)的 SDS 成员不进矩阵,整理后齐全。 + +派生默认(脚本层假设,显式可审查):工艺无关键字 → 烫画;印花数量无单/双面且工艺非不打印 → +单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)。 +「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。 + **公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。 公开端点**以款(族)为一等公民**: diff --git a/docs/references/structs.md b/docs/references/structs.md index 7f3cc9a..a490f82 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -123,6 +123,7 @@ apps/api/ | `/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/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT | | `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT | | `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT | | `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT | diff --git a/plans/refactor/organize-script-refactor.md b/plans/refactor/organize-script-refactor.md new file mode 100644 index 0000000..263b3ec --- /dev/null +++ b/plans/refactor/organize-script-refactor.md @@ -0,0 +1,64 @@ +# 解析去运行时化:同步纯镜像 + 整理脚本化 + 矩阵自动重算 + +## 背景 / 决策(用户拍板,2026-08-30) + +现行"同步即解析"模式系统性依赖上游链接命名(8 处解析点),上游改名会倒灌污染 +已正确的标签/矩阵(族重算按当前名称重派生标签),缺关键字时静默默认兜底。 + +最终架构: + +1. **同步 = 纯镜像**:SDS 给什么存什么,零解析、零归族 +2. **解析/派生/归族 = 显式脚本(OrganizeService)**:跑才生效,可重跑可审查; + 触发方式 = CLI + 后台"整理"按钮(都是人工显式动作) +3. **价格矩阵 = 归族后自动计算**:成员变化/详情同步/标签调整自动触发重算; + 输入只有结构化数据(成员变体 + 标签 + 覆盖价),**不碰名称**;人工可改价(覆盖表保留) + +## 变更清单 + +### A. sync.service 纯净化 +- 删 `parseOriginName` 解析列写入(skuCode/logisticsLabel/craftLabel/warehouseLabel + 不再随同步写入;存量数据保留,缺列由整理脚本回填) +- 删同步时自动归族(同分类/同名已配置族自动挂接的整段逻辑) +- 保留:详情同步后 enqueue 族重算(重算纯净化后是无解析的聚合) + +### B. family-recompute.service 纯净化 +- `recomputeFamily` 尾部不再调用 `syncFamilyTags`(标签永不随重算变) +- `memberMatrixCombos` 删名称兜底:SDS 成员维度只认标签,无标签不进矩阵; + CUSTOM 成员继续用显式 labels(管理员填写,非上游) +- 标签派生逻辑(deriveLinkTagNames 应用 + ensureDerivedTagMap + goods 镜像中的 + 派生部分)迁出到 OrganizeService;`mirrorLinkTagsToGoods`(纯镜像聚合)保留 + +### C. 新增 OrganizeService(product-families/organize.service.ts) +`organize(): { labelsParsed, tagsDerived, familiesCreated, familiesRecomputed, unparsable }` +1. 回填缺失的四个解析列(幂等,只补 null) +2. 派生标签:非人工接管(tagsManual=false)SDS 链接按名称刷新自动标签, + 人工接管的不动;随后镜像到名下商品 +3. `autoGroup` 建族(复用 ProductFamiliesService.autoGroup) +4. 全量族重算 +`deriveTagsForOg(ogId)` 供 resetTags(恢复自动,显式人工动作)复用 + +### D. 入口 +- CLI:`prisma/backfill-product-families.ts` 改薄壳调 OrganizeService; + package.json 加 `organize` 命令(与 backfill 等价) +- 端点:`POST /product-families/organize`(JWT) +- admin:商品页工具栏「整理」按钮 → 调端点 → toast 结果 + +### E. 测试(TDD) +- organize.service.spec:派生/幂等/人工接管保护/镜像 +- family-recompute.spec:无标签成员不进矩阵;重算不改标签 +- sync.spec:同步不写解析列、不归族 +- public / product-families / family-block spec:fixture 补标签(原靠重算时名称兜底) +- origin-goods resetTags:走 organize 派生 + +### F. 文档 +product-center.md 架构段落重写(同步/整理/重算三层)、structs.md 端点、README 命令 + +## 明确不做 +- 不删解析器/派生规则本身(脚本要用,规则不变) +- 不动人工接管/手动并族/覆盖价机制 +- normalizeGoodName(管理员输入边界质检)保留,随本分支一并提交 + +## 风险 +- 存量已正确标签不再被重派生覆盖(这是目标行为);但同步改名后名称与标签可能 + 不同步——由整理脚本显式刷新(人工决定何时跑) +- 新同步链接在整理前无标签无族(不进矩阵不进公开)——整理后齐全