import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service'; import { originGroupKey, parseOriginName, familyNameKey } from './origin-name.parser'; import { CreateCustomMemberDto, CreateProductFamilyDto, DeletePriceOverridesDto, PatchProductFamilyDto, PriceOverrideItemDto, QueryProductFamilyDto, UpdateFamilyMembersDto, } from './dto/product-family.dto'; /** 从分类名提取模型编码:`DG001 180G纯棉T恤(JSA002)` → `DG001` */ function codeFromCategoryName(categoryName: string | null | undefined): string | null { if (!categoryName) return null; const token = categoryName.trim().split(/\s+/)[0] ?? ''; return /^[A-Za-z0-9]+$/.test(token) ? token : null; } const FAMILY_INCLUDE = { originGoods: { orderBy: { id: 'asc' as const }, select: { id: true, sdsGoodId: true, goodName: true, goodImage: true, goodPrice: true, source: true, delisted: true, skuCode: true, logisticsLabel: true, craftLabel: true, warehouseLabel: true, tagsManual: true, originGoodTags: { orderBy: { tagId: 'asc' as const }, select: { id: true, manual: true, tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } }, }, }, _count: { select: { variants: true } }, }, }, priceOverrides: true, _count: { select: { originGoods: true, priceOverrides: true } }, } satisfies Prisma.ProductFamilyInclude; @Injectable() export class ProductFamiliesService { constructor( private readonly prisma: PrismaService, private readonly recompute: FamilyRecomputeService, ) {} async list(query: QueryProductFamilyDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.ProductFamilyWhereInput = query.keyword ? { OR: [ { familyName: { contains: query.keyword, mode: 'insensitive' } }, { familyCode: { contains: query.keyword, mode: 'insensitive' } }, ], } : {}; const [items, total] = await this.prisma.$transaction([ this.prisma.productFamily.findMany({ where, include: { _count: { select: { originGoods: true, priceOverrides: true } } }, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.productFamily.count({ where }), ]); return { items, total, page, pageSize }; } async detail(id: bigint) { const family = await this.prisma.productFamily.findUnique({ where: { id }, include: FAMILY_INCLUDE, }); if (!family) throw new NotFoundException(`product family ${id} not found`); return family; } async create(dto: CreateProductFamilyDto) { const family = await this.prisma.productFamily.create({ data: { familyName: dto.familyName, familyCode: dto.familyCode ? await this.ensureUniqueCode(dto.familyCode) : null, familyImage: dto.familyImage ?? null, countryId: dto.countryId ? BigInt(dto.countryId) : null, categoryId: dto.categoryId ? BigInt(dto.categoryId) : null, primaryOriginGoodId: dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null, }, }); if (dto.originGoodIds?.length) { await this.attachMembers( family.id, dto.originGoodIds.map((v) => BigInt(v)), ); } await this.recompute.recomputeFamily(family.id); return this.detail(family.id); } async patch(id: bigint, dto: PatchProductFamilyDto) { const existing = await this.prisma.productFamily.findUnique({ where: { id } }); if (!existing) throw new NotFoundException(`product family ${id} not found`); const data: Prisma.ProductFamilyUpdateInput = {}; if (dto.familyName !== undefined) data.familyName = dto.familyName; if (dto.familyImage !== undefined) data.familyImage = dto.familyImage; if (dto.autoManaged !== undefined) data.autoManaged = dto.autoManaged; if (dto.countryId !== undefined) { data.country = dto.countryId ? { connect: { id: BigInt(dto.countryId) } } : { disconnect: true }; } if (dto.categoryId !== undefined) { data.category = dto.categoryId ? { connect: { id: BigInt(dto.categoryId) } } : { disconnect: true }; } if (dto.familyCode !== undefined) { data.familyCode = dto.familyCode === '' ? null : dto.familyCode !== existing.familyCode ? await this.ensureUniqueCode(dto.familyCode, id) : existing.familyCode; } if (dto.primaryOriginGoodId !== undefined) { data.primaryOriginGoodId = dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null; } await this.prisma.productFamily.update({ where: { id }, data }); await this.recompute.recomputeFamily(id); return this.detail(id); } /** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"), * 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退族语义名称键 * (国家+品名+SKU,物流/工艺是矩阵维度不分族)。 * 已有同款 autoManaged 族时并入(不再新建 -2 碎片族);同款仅有 * 人工锁定族(autoManaged=false)时跳过该组,不并入也不新建。 * apply=false 仅预览(action 标注 create/merge/skip)。 */ async autoGroup(apply: boolean) { const candidates = await this.prisma.originGood.findMany({ where: { familyId: null, delisted: false }, select: { id: true, goodName: true, goodImage: true, source: true, sdsCategoryId: true }, orderBy: { id: 'asc' }, }); const categories = await this.prisma.category.findMany({ where: { sdsCategoryId: { not: null } }, select: { sdsCategoryId: true, categoryName: true }, }); const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName])); // 已有族的成员指纹:分类/名称键 → 候选族 id(用于"并入而非新建") const familyMembers = await this.prisma.originGood.findMany({ where: { familyId: { not: null }, delisted: false }, select: { familyId: true, sdsCategoryId: true, goodName: true }, }); const catFamilies = new Map(); const nameFamilies = new Map(); const push = (map: Map, key: string, id: bigint) => { map.set(key, [...(map.get(key) ?? []), id]); }; for (const m of familyMembers) { if (m.sdsCategoryId) push(catFamilies, m.sdsCategoryId, m.familyId!); const nk = familyNameKey(m.goodName); if (nk) push(nameFamilies, nk, m.familyId!); } const autoFamilyIds = new Set( ( await this.prisma.productFamily.findMany({ where: { autoManaged: true }, select: { id: true }, }) ).map((f) => f.id), ); /** 匹配可并入的已有族:分类优先,回退名称键;仅 autoManaged,多候选取最老 */ const matchAutoFamily = (cat: string | null, nameKey: string): bigint | null => { const pool = [ ...new Set([...(cat ? (catFamilies.get(cat) ?? []) : []), ...(nameKey ? (nameFamilies.get(nameKey) ?? []) : [])]), ] .filter((id) => autoFamilyIds.has(id)) .sort((a, b) => Number(a - b)); return pool[0] ?? null; }; /** 该组是否已有同款族(含人工锁定族,用于 skip 判断) */ const hasAnyFamily = (cat: string | null, nameKey: string): boolean => Boolean(cat && catFamilies.has(cat)) || Boolean(nameKey && nameFamilies.has(nameKey)); const groups = new Map(); const groupKeys = new Map(); for (const og of candidates) { const nameKey = familyNameKey(og.goodName); const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : ''; if (!key) continue; groupKeys.set(key, { cat: og.sdsCategoryId ?? null, nameKey }); const arr = groups.get(key); if (arr) arr.push(og); else groups.set(key, [og]); } const resolveGroup = (key: string) => { const members = groups.get(key)!; const { cat, nameKey } = groupKeys.get(key)!; const parsed = parseOriginName(members[0].goodName); const categoryName = cat ? (catName.get(cat) ?? null) : null; return { members, cat, nameKey, parsed, categoryName, familyName: categoryName ?? parsed.productName ?? nameKey, }; }; const preview = [...groups.keys()].map((key) => { const g = resolveGroup(key); const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null; const skip = !target && hasAnyFamily(g.cat, g.nameKey); return { groupKey: key, familyName: g.familyName, familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? null, memberCount: g.members.length, sampleNames: g.members.slice(0, 3).map((m) => m.goodName ?? ''), action: target ? ('merge' as const) : skip ? ('skip' as const) : ('create' as const), targetFamilyId: target ? target.toString() : null, }; }); if (!apply) return { applied: 0, merged: 0, skipped: [] as string[], groups: preview }; let applied = 0; let merged = 0; const skipped: string[] = []; for (const key of groups.keys()) { const g = resolveGroup(key); const memberIds = g.members.map((m) => m.id); const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null; if (target) { // 并入已有 autoManaged 族:不再新建 -2 碎片族 await this.attachMembers(target, memberIds, { strict: false }); await this.prisma.good.updateMany({ where: { originGoodId: { in: memberIds } }, data: { familyId: target }, }); await this.recompute.recomputeFamily(target); merged += memberIds.length; continue; } if (hasAnyFamily(g.cat, g.nameKey)) { // 同款仅有人工锁定族:不并入也不新建,留在候选里等人工处理 skipped.push(key); continue; } const fallbackCode = g.members[0].source === 'CUSTOM' ? `CUSTOM-${g.members[0].id}` : null; const family = await this.prisma.productFamily.create({ data: { familyName: g.familyName, familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode ? await this.ensureUniqueCode( (codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode)!, ) : null, familyImage: g.members[0].goodImage ?? null, primaryOriginGoodId: g.members[0].id, }, }); // 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可 await this.attachMembers( family.id, memberIds, { strict: false }, ); await this.prisma.good.updateMany({ where: { originGoodId: { in: memberIds } }, data: { familyId: family.id }, }); await this.recompute.recomputeFamily(family.id); applied += 1; } return { applied, merged, skipped, groups: preview }; } /** * 单链接自动归族:同 SDS 分类优先,回退族语义名称键(国家+品名+SKU,物流/工艺 * 是矩阵维度不分族);只并入 autoManaged 族(人工锁定族不动),多候选取最老 * (id 最小)。返回并入的族 id,无匹配返回 null。 */ async attachToMatchingFamily(originGoodId: bigint): Promise { const og = await this.prisma.originGood.findUnique({ where: { id: originGoodId }, select: { id: true, sdsCategoryId: true, goodName: true, delisted: true, familyId: true }, }); if (!og || og.familyId || og.delisted) return null; const members = await this.prisma.originGood.findMany({ where: { familyId: { not: null }, delisted: false }, select: { familyId: true, sdsCategoryId: true, goodName: true }, }); let candidates: bigint[] = []; if (og.sdsCategoryId) { candidates = members .filter((m) => m.sdsCategoryId === og.sdsCategoryId) .map((m) => m.familyId!); } if (!candidates.length) { const key = familyNameKey(og.goodName); if (key) { candidates = members .filter((m) => familyNameKey(m.goodName) === key) .map((m) => m.familyId!); } } if (!candidates.length) return null; const families = await this.prisma.productFamily.findMany({ where: { id: { in: [...new Set(candidates)] }, autoManaged: true }, orderBy: { id: 'asc' }, select: { id: true }, }); if (!families.length) return null; const target = families[0]; await this.attachMembers(target.id, [og.id]); await this.prisma.good.updateMany({ where: { originGoodId: og.id }, data: { familyId: target.id }, }); await this.recompute.recomputeFamily(target.id); return target.id; } async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) { const family = await this.prisma.productFamily.findUnique({ where: { id } }); if (!family) throw new NotFoundException(`product family ${id} not found`); if (dto.removeOriginGoodIds?.length) { const removeIds = dto.removeOriginGoodIds.map((v) => BigInt(v)); const remaining = await this.prisma.originGood.count({ where: { familyId: id, id: { notIn: removeIds } }, }); await this.prisma.originGood.updateMany({ where: { id: { in: removeIds }, familyId: id }, data: { familyId: null }, }); await this.prisma.good.updateMany({ where: { originGoodId: { in: removeIds }, familyId: id }, data: { familyId: null }, }); // 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员 if (remaining > 0) { const stillPrimary = await this.prisma.originGood.count({ where: { familyId: id, id: family.primaryOriginGoodId ?? -1n }, }); if (!stillPrimary) { const next = await this.prisma.originGood.findFirst({ where: { familyId: id }, orderBy: { id: 'asc' }, select: { id: true }, }); if (next) { await this.prisma.productFamily.update({ where: { id }, data: { primaryOriginGoodId: next.id }, }); } } } else { await this.prisma.productFamily.update({ where: { id }, data: { primaryOriginGoodId: null }, }); } } if (dto.addOriginGoodIds?.length) { const addIds = dto.addOriginGoodIds.map((v) => BigInt(v)); await this.attachMembers(id, addIds); await this.prisma.good.updateMany({ where: { originGoodId: { in: addIds } }, data: { familyId: id }, }); } await this.recompute.recomputeFamily(id); return this.detail(id); } /** 在族内创建自定义成员(人工商品),成功后重算 */ async createCustomMember(familyId: bigint, dto: CreateCustomMemberDto) { const family = await this.prisma.productFamily.findUnique({ where: { id: familyId } }); if (!family) throw new NotFoundException(`product family ${familyId} not found`); const { randomUUID } = await import('node:crypto'); const originGood = await this.prisma.originGood.create({ data: { sdsGoodId: `custom-${randomUUID()}`, goodName: dto.goodName, goodImage: dto.goodImage ?? null, source: 'CUSTOM', familyId, skuCode: dto.skuCode ?? null, logisticsLabel: dto.logisticsLabel, craftLabel: dto.craftLabel, warehouseLabel: dto.warehouseLabel ?? null, }, }); await this.prisma.originGoodVariant.createMany({ data: dto.variants.map((v) => ({ originGoodId: originGood.id, sdsVariantId: `custom-${randomUUID()}`, sku: v.sku, sizeId: v.sizeId ?? null, sizeName: v.sizeName ?? null, colorId: v.colorId ?? null, colorName: v.colorName ?? null, colorHex: v.colorHex ?? null, imageUrl: v.imageUrl ?? null, price: new Prisma.Decimal(v.price), })), }); if (dto.detail?.sizeChart || dto.detail?.packageSpecs) { await this.prisma.originGoodDetail.create({ data: { originGoodId: originGood.id, sizeChart: (dto.detail?.sizeChart ?? undefined) as Prisma.InputJsonValue, packageSpecs: (dto.detail?.packageSpecs ?? undefined) as Prisma.InputJsonValue, }, }); } if (!family.primaryOriginGoodId) { await this.prisma.productFamily.update({ where: { id: familyId }, data: { primaryOriginGoodId: originGood.id }, }); } await this.recompute.recomputeFamily(familyId); return originGood; } async listPriceOverrides(id: bigint) { const family = await this.prisma.productFamily.findUnique({ where: { id } }); if (!family) throw new NotFoundException(`product family ${id} not found`); const overrides = await this.prisma.familyPriceOverride.findMany({ where: { familyId: id }, orderBy: { updatedAt: 'desc' }, }); const matrix = (family.priceMatrix as PriceMatrix | null) ?? null; const rows = matrix?.rows ?? []; return { items: overrides.map((o) => { // 推导价 = 该格子全部来源中的最低价(覆盖生效前的推导结果,保留在 sources 里) const row = rows.find( (r) => r.sizeId === o.sizeId && r.colorId === o.colorId && r.printCount === o.printCount && r.craft === o.craft && r.logistics === o.logistics, ); const derived = row?.sources.length && !row.manual ? row.price : row?.sources.length ? String(Math.min(...row.sources.map((s) => Number(s.price)))) : null; return { ...o, derivedPrice: derived, diff: derived !== null ? (Number(o.price) - Number(derived)).toFixed(2) : null, }; }), }; } async putPriceOverrides(id: bigint, items: PriceOverrideItemDto[]) { const family = await this.prisma.productFamily.findUnique({ where: { id } }); if (!family) throw new NotFoundException(`product family ${id} not found`); // 矩阵未物化(如刚建族)时先重算,保证维度校验有依据 let matrix = family.priceMatrix as PriceMatrix | null; if (!matrix) { await this.recompute.recomputeFamily(id); const refreshed = await this.prisma.productFamily.findUnique({ where: { id } }); matrix = (refreshed?.priceMatrix as PriceMatrix | null) ?? null; } const allowed = { sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)), colors: new Set((matrix?.colors ?? []).map((c) => c.key)), printCounts: new Set(matrix?.printCounts ?? []), crafts: new Set(matrix?.crafts ?? []), logistics: new Set(matrix?.logistics ?? []), }; const invalid = items.filter( (i) => !allowed.sizes.has(i.sizeId) || !allowed.colors.has(i.colorId) || !allowed.printCounts.has(i.printCount) || !allowed.crafts.has(i.craft) || !allowed.logistics.has(i.logistics), ); if (invalid.length) { throw new BadRequestException({ message: 'price override dimensions must exist in the family matrix', invalidCells: invalid.map((i) => ({ sizeId: i.sizeId, colorId: i.colorId, printCount: i.printCount, craft: i.craft, logistics: i.logistics, })), }); } for (const item of items) { await this.prisma.familyPriceOverride.upsert({ where: { familyId_sizeId_colorId_printCount_craft_logistics: { familyId: id, sizeId: item.sizeId, colorId: item.colorId, printCount: item.printCount, craft: item.craft, logistics: item.logistics, }, }, create: { familyId: id, sizeId: item.sizeId, colorId: item.colorId, printCount: item.printCount, craft: item.craft, logistics: item.logistics, price: new Prisma.Decimal(item.price), note: item.note ?? null, }, update: { price: new Prisma.Decimal(item.price), note: item.note ?? null, }, }); } await this.recompute.recomputeFamily(id); return this.listPriceOverrides(id); } async deletePriceOverrides(id: bigint, dto: DeletePriceOverridesDto) { for (const cell of dto.cells) { await this.prisma.familyPriceOverride.deleteMany({ where: { familyId: id, sizeId: cell.sizeId, colorId: cell.colorId, printCount: cell.printCount, craft: cell.craft, logistics: cell.logistics, }, }); } await this.recompute.recomputeFamily(id); return this.listPriceOverrides(id); } async recomputeNow(id: bigint) { const family = await this.prisma.productFamily.findUnique({ where: { id } }); if (!family) throw new NotFoundException(`product family ${id} not found`); await this.recompute.recomputeFamily(id); return this.detail(id); } private async attachMembers( familyId: bigint, originGoodIds: bigint[], opts: { strict?: boolean } = {}, ) { if (!originGoodIds.length) return; const existings = await this.prisma.originGood.findMany({ where: { id: { in: originGoodIds } }, select: { id: true }, }); const existingIds = new Set(existings.map((e) => e.id.toString())); const missing = originGoodIds.filter((v) => !existingIds.has(v.toString())); if (missing.length && opts.strict !== false) { throw new BadRequestException({ message: 'origin goods not found', ids: missing.map(String), }); } const attachable = originGoodIds.filter((v) => existingIds.has(v.toString())); if (!attachable.length) return; await this.prisma.originGood.updateMany({ where: { id: { in: attachable } }, data: { familyId }, }); } /** familyCode 全局唯一:冲突时追加 -2/-3… 后缀(不同国家同 SKU 常见) */ private async ensureUniqueCode(code: string, selfId?: bigint): Promise { let candidate = code; let seq = 2; // eslint-disable-next-line no-constant-condition while (true) { const clash = await this.prisma.productFamily.findFirst({ where: { familyCode: candidate, ...(selfId ? { id: { not: selfId } } : {}) }, select: { id: true }, }); if (!clash) return candidate; candidate = `${code}-${seq++}`; } } }