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, _count: { select: { originGoods: 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), }, }); } } }