feat(api): product families module with CRUD, auto-group, members, custom members and price overrides
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
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 } from './origin-name.parser';
|
||||
import {
|
||||
CreateCustomMemberDto,
|
||||
CreateProductFamilyDto,
|
||||
DeletePriceOverridesDto,
|
||||
PatchProductFamilyDto,
|
||||
PriceOverrideItemDto,
|
||||
QueryProductFamilyDto,
|
||||
UpdateFamilyMembersDto,
|
||||
} from './dto/product-family.dto';
|
||||
|
||||
const FAMILY_INCLUDE = {
|
||||
originGoods: {
|
||||
select: {
|
||||
id: true,
|
||||
sdsGoodId: true,
|
||||
goodName: true,
|
||||
goodImage: true,
|
||||
source: true,
|
||||
delisted: true,
|
||||
skuCode: true,
|
||||
logisticsLabel: true,
|
||||
craftLabel: true,
|
||||
warehouseLabel: 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);
|
||||
}
|
||||
|
||||
/** 自动建族:按 3 段分组键聚合无族链接;apply=false 仅预览 */
|
||||
async autoGroup(apply: boolean) {
|
||||
const candidates = await this.prisma.originGood.findMany({
|
||||
where: { familyId: null, delisted: false },
|
||||
select: { id: true, goodName: true, goodImage: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, typeof candidates>();
|
||||
for (const og of candidates) {
|
||||
const key = originGroupKey(og.goodName);
|
||||
if (!key) continue;
|
||||
const arr = groups.get(key);
|
||||
if (arr) arr.push(og);
|
||||
else groups.set(key, [og]);
|
||||
}
|
||||
|
||||
const preview = [...groups.values()].map((members) => {
|
||||
const parsed = parseOriginName(members[0].goodName);
|
||||
return {
|
||||
groupKey: originGroupKey(members[0].goodName),
|
||||
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
||||
familyCode: parsed.skuCode ?? null,
|
||||
memberCount: members.length,
|
||||
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
|
||||
};
|
||||
});
|
||||
|
||||
if (!apply) return { applied: 0, groups: preview };
|
||||
|
||||
let applied = 0;
|
||||
for (const members of groups.values()) {
|
||||
const parsed = parseOriginName(members[0].goodName);
|
||||
const family = await this.prisma.productFamily.create({
|
||||
data: {
|
||||
familyName: parsed.productName ?? parsed.country ?? originGroupKey(members[0].goodName),
|
||||
familyCode: parsed.skuCode ? await this.ensureUniqueCode(parsed.skuCode) : null,
|
||||
familyImage: members[0].goodImage ?? null,
|
||||
primaryOriginGoodId: members[0].id,
|
||||
},
|
||||
});
|
||||
await this.attachMembers(
|
||||
family.id,
|
||||
members.map((m) => m.id),
|
||||
);
|
||||
await this.recompute.recomputeFamily(family.id);
|
||||
applied += 1;
|
||||
}
|
||||
return { applied, groups: preview };
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
|
||||
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) {
|
||||
await this.attachMembers(
|
||||
id,
|
||||
dto.addOriginGoodIds.map((v) => BigInt(v)),
|
||||
);
|
||||
}
|
||||
|
||||
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.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)),
|
||||
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.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,
|
||||
craft: i.craft,
|
||||
logistics: i.logistics,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
await this.prisma.familyPriceOverride.upsert({
|
||||
where: {
|
||||
familyId_sizeId_colorId_craft_logistics: {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
craft: item.craft,
|
||||
logistics: item.logistics,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
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,
|
||||
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[]) {
|
||||
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) {
|
||||
throw new BadRequestException({
|
||||
message: 'origin goods not found',
|
||||
ids: missing.map(String),
|
||||
});
|
||||
}
|
||||
await this.prisma.originGood.updateMany({
|
||||
where: { id: { in: originGoodIds } },
|
||||
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++}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user