问题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 夹具改为自包含(不依赖共享库既有数据)。
626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
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++}`;
|
||
}
|
||
}
|
||
}
|