import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { OrganizeService } from '../product-families/organize.service'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; /** 链接标签(origin_good_tags 行,含人工/派生标记) */ export interface OriginGoodTagItem { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; manual: boolean; } export interface OriginGoodTagsResult { tagsManual: boolean; tags: OriginGoodTagItem[]; } export interface PaginatedOriginGoods { items: Array<{ id: string; sdsGoodId: string; goodName: string | null; goodImage: string | null; goodPrice: string | null; sdsCategoryId: string | null; createdAt: string; updatedAt: string; hasDetail: boolean; detailSyncedAt: string | null; variantCount: number; }>; total: number; page: number; pageSize: number; } /** * A single origin-good node inside the tree, augmented with configuration status * (how many `goods` rows reference it and which countries it has been configured for). */ export interface OriginGoodsTreeNode { id: string; goodName: string; goodImage: string | null; goodPrice: string | null; sdsGoodId: string; delisted: boolean; configuredCount: number; configuredCountries: string[]; configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]; hasDetail: boolean; detailSyncedAt: string | null; variantCount: number; sizeRowCount: number; packageRowCount: number; familyId: string | null; familyName: string | null; familyCode: string | null; familyStale: boolean | null; } /** A category node in the hierarchical tree, with origin goods as leaves. */ export interface OriginGoodsTreeCategoryNode { categoryId: string; categoryName: string; sdsCategoryId: string | null; configuredCount: number; totalCount: number; children: OriginGoodsTreeCategoryNode[]; originGoods: OriginGoodsTreeNode[]; } /** Top-level tree response returned by `OriginGoodsService.getTree()`. */ export interface OriginGoodsTreeResponse { tree: OriginGoodsTreeCategoryNode[]; totalOriginGoods: number; configuredCount: number; } @Injectable() export class OriginGoodsService { constructor( private readonly prisma: PrismaService, private readonly familyRecompute: FamilyRecomputeService, private readonly organize: OrganizeService, ) {} /** 链接当前标签(含 manual 标记) */ async getTags(id: bigint): Promise { const og = await this.prisma.originGood.findUnique({ where: { id }, include: { originGoodTags: { include: { tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } }, }, orderBy: { tagId: 'asc' }, }, }, }); if (!og) throw new NotFoundException(`Origin good ${id} not found`); return { tagsManual: og.tagsManual, tags: og.originGoodTags.map((r) => ({ id: r.tag.id.toString(), tagName: r.tag.tagName, tagColor: r.tag.tagColor, tagFontColor: r.tag.tagFontColor, manual: r.manual, })), }; } /** * 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖), * 并把有效标签镜像到该链接名下的商品。 */ async updateTags(id: bigint, tagIds: number[]): Promise { const og = await this.prisma.originGood.findUnique({ where: { id }, select: { id: true }, }); if (!og) throw new NotFoundException(`Origin good ${id} not found`); const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) => Number(a - b), ); if (uniqueIds.length) { const count = await this.prisma.tag.count({ where: { id: { in: uniqueIds } } }); if (count !== uniqueIds.length) { throw new BadRequestException('存在无效标签'); } } await this.prisma.$transaction([ this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }), ...(!uniqueIds.length ? [] : [ this.prisma.originGoodTag.createMany({ data: uniqueIds.map((tagId) => ({ originGoodId: id, tagId, manual: true, })), }), ]), this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }), ]); await this.familyRecompute.mirrorLinkTagsToGoods(id); // 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算) 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 }, select: { id: true, familyId: true }, }); if (!og) throw new NotFoundException(`Origin good ${id} not found`); await this.prisma.$transaction([ this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }), this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }), ]); // 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作 await this.organize.deriveTagsForOg(id); if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId); return this.getTags(id); } /** 单链接详情(含 detail 与 variants,成员展开面板用) */ async findOne(id: bigint) { const og = await this.prisma.originGood.findUnique({ where: { id }, include: { detail: true, variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, _count: { select: { variants: true } }, }, }); if (!og) throw new NotFoundException(`Origin good ${id} not found`); const detail = og.detail; return { id: og.id.toString(), sdsGoodId: og.sdsGoodId, goodName: og.goodName, goodImage: og.goodImage, goodPrice: og.goodPrice === null || og.goodPrice === undefined ? null : og.goodPrice.toString(), source: og.source, delisted: og.delisted, hasDetail: Boolean(detail), detailSyncedAt: detail?.syncedAt.toISOString() ?? null, variantCount: og._count.variants, detail: detail ? { productCode: detail.productCode, englishName: detail.englishName, productionCycleHours: detail.productionCycleHours, minWeightG: detail.minWeightG === null ? null : detail.minWeightG.toString(), productionProcess: detail.productionProcess, materialDescription: detail.materialDescription, sizeChart: (detail.sizeChart ?? null) as unknown, packageSpecs: (detail.packageSpecs ?? null) as unknown, } : null, variants: og.variants.map((v) => ({ sdsVariantId: v.sdsVariantId, sku: v.sku, sizeId: v.sizeId, sizeName: v.sizeName, colorId: v.colorId, colorName: v.colorName, price: v.price === null ? null : v.price.toString(), enabled: v.enabled, })), }; } async findAll(query: QueryOriginGoodDto): Promise { const { page, pageSize, keyword } = query; const where: Prisma.OriginGoodWhereInput = { source: 'SDS', ...(keyword ? { goodName: { contains: keyword, mode: 'insensitive' as const } } : {}), }; const [total, rows] = await this.prisma.$transaction([ this.prisma.originGood.count({ where }), this.prisma.originGood.findMany({ where, orderBy: { id: 'desc' }, include: { detail: true, _count: { select: { variants: true } } }, skip: (page - 1) * pageSize, take: pageSize, }), ]); return { items: rows.map((r) => ({ id: r.id.toString(), sdsGoodId: r.sdsGoodId, goodName: r.goodName, goodImage: r.goodImage, goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(), sdsCategoryId: r.sdsCategoryId, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), hasDetail: Boolean(r.detail), detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null, variantCount: r._count.variants, })), total, page, pageSize, }; } /** * Builds a hierarchical tree using the `categories` table parent-child * structure, placing each origin-good as a leaf under the category whose * `sdsCategoryId` matches the origin-good's `sdsCategoryId`. * * Origin-goods whose `sdsCategoryId` doesn't map to any category are placed * under a synthetic "未分类" root node. */ async getTree(): Promise { const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] = await Promise.all([ this.prisma.category.findMany({ where: { sdsCategoryId: { not: null } }, orderBy: { categoryName: 'asc' }, select: { id: true, categoryName: true, sdsCategoryId: true, parentCategoryId: true, }, }), this.prisma.originGood.findMany({ where: { delisted: false, source: 'SDS' }, orderBy: { goodName: 'asc' }, include: { detail: true, _count: { select: { variants: true } }, family: { select: { id: true, familyName: true, familyCode: true, stale: true } }, }, }), this.prisma.good.groupBy({ by: ['originGoodId'], _count: { _all: true }, }), this.prisma.good.findMany({ select: { originGoodId: true, country: { select: { countryName: true } }, }, distinct: ['originGoodId', 'countryId'], }), this.prisma.goodTag.findMany({ select: { good: { select: { originGoodId: true, mergedOriginGoods: { select: { originGoodId: true } }, }, }, tag: { select: { tagName: true, tagColor: true, tagFontColor: true, tagGroupId: true, sortOrder: true, tagGroup: { select: { groupName: true } }, }, }, }, }), // Secondary-source references (good_origin_goods) this.prisma.goodOriginGood.groupBy({ by: ['originGoodId'], _count: { _all: true }, }), this.prisma.goodOriginGood.findMany({ select: { originGoodId: true, good: { select: { country: { select: { countryName: true } } } }, }, }), ]); const countMap = new Map(); configCounts.forEach((c) => countMap.set(c.originGoodId.toString(), c._count._all), ); // Secondary (merged) references count towards configured status too. mergedCounts.forEach((c) => { const key = c.originGoodId.toString(); countMap.set(key, (countMap.get(key) ?? 0) + c._count._all); }); const countryMap = new Map(); const addCountry = (key: string, name?: string | null) => { if (!name) return; const arr = countryMap.get(key); if (!arr?.includes(name)) { countryMap.set(key, [...(arr ?? []), name]); } }; goodsWithCountries.forEach((g) => addCountry(g.originGoodId.toString(), g.country?.countryName), ); mergedWithCountries.forEach((m) => addCountry(m.originGoodId.toString(), m.good.country?.countryName), ); const tagMap = new Map(); const addTag = ( key: string, tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }, ) => { const arr = tagMap.get(key); if (arr) { if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo); } else { tagMap.set(key, [tagInfo]); } }; goodsWithTags.forEach((gt) => { const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, tagFontColor: gt.tag.tagFontColor, tagGroupId: gt.tag.tagGroupId?.toString() ?? null, tagGroupName: gt.tag.tagGroup?.groupName ?? null, sortOrder: gt.tag.sortOrder, }; addTag(gt.good.originGoodId.toString(), tagInfo); // A good's tags also mark its secondary origin goods as configured. for (const m of gt.good.mergedOriginGoods) { addTag(m.originGoodId.toString(), tagInfo); } }); const sdsToCategory = new Map< string, (typeof allCategories)[number] >(); for (const c of allCategories) { if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c); } const ogToCategory = new Map(); for (const og of allOriginGoods) { if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) { ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString()); } } const buildNode = ( cat: (typeof allCategories)[number], ): OriginGoodsTreeCategoryNode => { const childrenCats = allCategories.filter( (c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id, ); const childNodes = childrenCats .map(buildNode) .filter((n) => n.totalCount > 0); const ogsForThisCat = allOriginGoods.filter( (og) => ogToCategory.get(og.id.toString()) === cat.id.toString(), ); const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({ id: og.id.toString(), goodName: og.goodName ?? `SDS-${og.sdsGoodId}`, goodImage: og.goodImage, goodPrice: og.goodPrice?.toString() ?? null, sdsGoodId: og.sdsGoodId, delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], configuredTags: tagMap.get(og.id.toString()) ?? [], hasDetail: Boolean(og.detail), detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, variantCount: og._count.variants, sizeRowCount: this.jsonRows(og.detail?.sizeChart), packageRowCount: this.jsonRows(og.detail?.packageSpecs), familyId: og.family?.id.toString() ?? null, familyName: og.family?.familyName ?? null, familyCode: og.family?.familyCode ?? null, familyStale: og.family?.stale ?? null, })); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); const childConfigured = childNodes.reduce( (s, n) => s + n.configuredCount, 0, ); const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length; return { categoryId: cat.id.toString(), categoryName: cat.categoryName, sdsCategoryId: cat.sdsCategoryId, configuredCount: childConfigured + ogConfigured, totalCount: childTotal + ogNodes.length, children: childNodes, originGoods: ogNodes, }; }; const roots = allCategories.filter((c) => c.parentCategoryId === null); const tree = roots.map(buildNode).filter((n) => n.totalCount > 0); const unmapped = allOriginGoods.filter( (og) => !ogToCategory.has(og.id.toString()), ); if (unmapped.length > 0) { tree.push({ categoryId: 'uncategorized', categoryName: '未分类', sdsCategoryId: null, configuredCount: unmapped.filter( (og) => (countMap.get(og.id.toString()) ?? 0) > 0, ).length, totalCount: unmapped.length, children: [], originGoods: unmapped.map((og) => ({ id: og.id.toString(), goodName: og.goodName ?? `SDS-${og.sdsGoodId}`, goodImage: og.goodImage, goodPrice: og.goodPrice?.toString() ?? null, sdsGoodId: og.sdsGoodId, delisted: og.delisted, configuredCount: countMap.get(og.id.toString()) ?? 0, configuredCountries: countryMap.get(og.id.toString()) ?? [], configuredTags: tagMap.get(og.id.toString()) ?? [], hasDetail: Boolean(og.detail), detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null, variantCount: og._count.variants, sizeRowCount: this.jsonRows(og.detail?.sizeChart), packageRowCount: this.jsonRows(og.detail?.packageSpecs), familyId: og.family?.id.toString() ?? null, familyName: og.family?.familyName ?? null, familyCode: og.family?.familyCode ?? null, familyStale: og.family?.stale ?? null, })), }); } tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh')); const totalConfigured = allOriginGoods.filter( (og) => (countMap.get(og.id.toString()) ?? 0) > 0, ).length; return { tree, totalOriginGoods: allOriginGoods.length, configuredCount: totalConfigured, }; } private jsonRows(value: unknown): number { if (!value || typeof value !== 'object' || !('rows' in value)) return 0; const rows = (value as { rows?: unknown }).rows; return Array.isArray(rows) ? rows.length : 0; } }