Files
inkreach-official-website/apps/api/src/product-families/product-families.service.ts
T
yeuimu f1e81872e7 fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path
问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复:
- 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES
  别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效)
- updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 →
  缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags)
- autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有
  autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告
- 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键
  familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复

附加:
- updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带
  attachedFamilyId)
- 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除
  (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告
- admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数

问题1(后台频繁 Unauthorized 掉线):
- refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器
  永远带不上 refresh cookie → 两 cookie path 统一为 '/'
- v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 →
  request.ts 规范拼接

测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误;
public.service 夹具改为自包含(不依赖共享库既有数据)。
2026-09-02 18:39:35 +08:00

626 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, bigint[]>();
const nameFamilies = new Map<string, bigint[]>();
const push = (map: Map<string, bigint[]>, 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<string, typeof candidates>();
const groupKeys = new Map<string, { cat: string | null; nameKey: string }>();
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<bigint | null> {
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<string> {
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++}`;
}
}
}