解析去运行时化(三层架构,plans/refactor/organize-script-refactor.md): - 同步 = 纯镜像:upsertOriginGood 不再写解析列、不再自动挂族;详情同步仍触发重算 - 整理 = 显式人工动作(OrganizeService):解析列回填 → 派生标签(人工接管永不 覆盖)→ auto-group 建族 → 全量重算;入口 CLI(pnpm --filter @inkreach/api organize)+ POST /product-families/organize + 后台「整理」按钮 - 重算 = 纯结构化聚合:不再按名称重派生标签(防上游改名倒灌,回归测试覆盖); 矩阵维度只认标签/CUSTOM 显式标签,未整理成员不进矩阵;工艺=不打印时 印花数量以单面占位(纯结构化规则);「恢复自动」走整理的单链接派生 - 派生默认补齐(脚本层假设):名称无单/双面且工艺非不打印 → 印花数量单面印花 - goods 服务建品/更新后仅镜像标签+重算(不派生);含商品名入库规范化 (normalizeGoodName,管理员输入边界质检) - organize.service.spec 由 tag-sync spec 迁移 + 防倒灌回归;sync/recompute/ public/families spec 全部适配;API 173/173,admin typecheck+22/22
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
|
import { OrganizeService } from '../product-families/organize.service';
|
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
|
|
|
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
|
export interface OriginGoodTagItem {
|
|
id: string;
|
|
tagName: string;
|
|
tagColor: string | null;
|
|
tagFontColor: string | null;
|
|
manual: boolean;
|
|
}
|
|
|
|
export interface OriginGoodTagsResult {
|
|
tagsManual: boolean;
|
|
tags: OriginGoodTagItem[];
|
|
}
|
|
|
|
export interface PaginatedOriginGoods {
|
|
items: Array<{
|
|
id: string;
|
|
sdsGoodId: string;
|
|
goodName: string | null;
|
|
goodImage: string | null;
|
|
goodPrice: string | null;
|
|
sdsCategoryId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
hasDetail: boolean;
|
|
detailSyncedAt: string | null;
|
|
variantCount: number;
|
|
}>;
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
/**
|
|
* A single origin-good node inside the tree, augmented with configuration status
|
|
* (how many `goods` rows reference it and which countries it has been configured for).
|
|
*/
|
|
export interface OriginGoodsTreeNode {
|
|
id: string;
|
|
goodName: string;
|
|
goodImage: string | null;
|
|
goodPrice: string | null;
|
|
sdsGoodId: string;
|
|
delisted: boolean;
|
|
configuredCount: number;
|
|
configuredCountries: string[];
|
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
|
hasDetail: boolean;
|
|
detailSyncedAt: string | null;
|
|
variantCount: number;
|
|
sizeRowCount: number;
|
|
packageRowCount: number;
|
|
familyId: string | null;
|
|
familyName: string | null;
|
|
familyCode: string | null;
|
|
familyStale: boolean | null;
|
|
}
|
|
|
|
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
|
export interface OriginGoodsTreeCategoryNode {
|
|
categoryId: string;
|
|
categoryName: string;
|
|
sdsCategoryId: string | null;
|
|
configuredCount: number;
|
|
totalCount: number;
|
|
children: OriginGoodsTreeCategoryNode[];
|
|
originGoods: OriginGoodsTreeNode[];
|
|
}
|
|
|
|
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
|
export interface OriginGoodsTreeResponse {
|
|
tree: OriginGoodsTreeCategoryNode[];
|
|
totalOriginGoods: number;
|
|
configuredCount: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class OriginGoodsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly familyRecompute: FamilyRecomputeService,
|
|
private readonly organize: OrganizeService,
|
|
) {}
|
|
|
|
/** 链接当前标签(含 manual 标记) */
|
|
async getTags(id: bigint): Promise<OriginGoodTagsResult> {
|
|
const og = await this.prisma.originGood.findUnique({
|
|
where: { id },
|
|
include: {
|
|
originGoodTags: {
|
|
include: {
|
|
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
|
|
},
|
|
orderBy: { tagId: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
|
return {
|
|
tagsManual: og.tagsManual,
|
|
tags: og.originGoodTags.map((r) => ({
|
|
id: r.tag.id.toString(),
|
|
tagName: r.tag.tagName,
|
|
tagColor: r.tag.tagColor,
|
|
tagFontColor: r.tag.tagFontColor,
|
|
manual: r.manual,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖),
|
|
* 并把有效标签镜像到该链接名下的商品。
|
|
*/
|
|
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
|
|
const og = await this.prisma.originGood.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
|
const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
|
|
Number(a - b),
|
|
);
|
|
if (uniqueIds.length) {
|
|
const count = await this.prisma.tag.count({ where: { id: { in: uniqueIds } } });
|
|
if (count !== uniqueIds.length) {
|
|
throw new BadRequestException('存在无效标签');
|
|
}
|
|
}
|
|
await this.prisma.$transaction([
|
|
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
|
...(!uniqueIds.length
|
|
? []
|
|
: [
|
|
this.prisma.originGoodTag.createMany({
|
|
data: uniqueIds.map((tagId) => ({
|
|
originGoodId: id,
|
|
tagId,
|
|
manual: true,
|
|
})),
|
|
}),
|
|
]),
|
|
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
|
]);
|
|
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
|
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
|
|
const ogFull = await this.prisma.originGood.findUnique({
|
|
where: { id },
|
|
select: { familyId: true },
|
|
});
|
|
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
|
|
return this.getTags(id);
|
|
}
|
|
|
|
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
|
|
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
|
|
const og = await this.prisma.originGood.findUnique({
|
|
where: { id },
|
|
select: { id: true, familyId: true },
|
|
});
|
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
|
await this.prisma.$transaction([
|
|
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
|
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
|
|
]);
|
|
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
|
|
await this.organize.deriveTagsForOg(id);
|
|
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
|
|
return this.getTags(id);
|
|
}
|
|
|
|
/** 单链接详情(含 detail 与 variants,成员展开面板用) */
|
|
async findOne(id: bigint) {
|
|
const og = await this.prisma.originGood.findUnique({
|
|
where: { id },
|
|
include: {
|
|
detail: true,
|
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
_count: { select: { variants: true } },
|
|
},
|
|
});
|
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
|
const detail = og.detail;
|
|
return {
|
|
id: og.id.toString(),
|
|
sdsGoodId: og.sdsGoodId,
|
|
goodName: og.goodName,
|
|
goodImage: og.goodImage,
|
|
goodPrice: og.goodPrice === null || og.goodPrice === undefined ? null : og.goodPrice.toString(),
|
|
source: og.source,
|
|
delisted: og.delisted,
|
|
hasDetail: Boolean(detail),
|
|
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
|
variantCount: og._count.variants,
|
|
detail: detail
|
|
? {
|
|
productCode: detail.productCode,
|
|
englishName: detail.englishName,
|
|
productionCycleHours: detail.productionCycleHours,
|
|
minWeightG: detail.minWeightG === null ? null : detail.minWeightG.toString(),
|
|
productionProcess: detail.productionProcess,
|
|
materialDescription: detail.materialDescription,
|
|
sizeChart: (detail.sizeChart ?? null) as unknown,
|
|
packageSpecs: (detail.packageSpecs ?? null) as unknown,
|
|
}
|
|
: null,
|
|
variants: og.variants.map((v) => ({
|
|
sdsVariantId: v.sdsVariantId,
|
|
sku: v.sku,
|
|
sizeId: v.sizeId,
|
|
sizeName: v.sizeName,
|
|
colorId: v.colorId,
|
|
colorName: v.colorName,
|
|
price: v.price === null ? null : v.price.toString(),
|
|
enabled: v.enabled,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
|
const { page, pageSize, keyword } = query;
|
|
const where: Prisma.OriginGoodWhereInput = {
|
|
source: 'SDS',
|
|
...(keyword
|
|
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
|
|
: {}),
|
|
};
|
|
|
|
const [total, rows] = await this.prisma.$transaction([
|
|
this.prisma.originGood.count({ where }),
|
|
this.prisma.originGood.findMany({
|
|
where,
|
|
orderBy: { id: 'desc' },
|
|
include: { detail: true, _count: { select: { variants: true } } },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
items: rows.map((r) => ({
|
|
id: r.id.toString(),
|
|
sdsGoodId: r.sdsGoodId,
|
|
goodName: r.goodName,
|
|
goodImage: r.goodImage,
|
|
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
|
sdsCategoryId: r.sdsCategoryId,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
hasDetail: Boolean(r.detail),
|
|
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
|
|
variantCount: r._count.variants,
|
|
})),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds a hierarchical tree using the `categories` table parent-child
|
|
* structure, placing each origin-good as a leaf under the category whose
|
|
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
|
*
|
|
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
|
* under a synthetic "未分类" root node.
|
|
*/
|
|
async getTree(): Promise<OriginGoodsTreeResponse> {
|
|
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
|
|
await Promise.all([
|
|
this.prisma.category.findMany({
|
|
where: { sdsCategoryId: { not: null } },
|
|
orderBy: { categoryName: 'asc' },
|
|
select: {
|
|
id: true,
|
|
categoryName: true,
|
|
sdsCategoryId: true,
|
|
parentCategoryId: true,
|
|
},
|
|
}),
|
|
this.prisma.originGood.findMany({
|
|
where: { delisted: false, source: 'SDS' },
|
|
orderBy: { goodName: 'asc' },
|
|
include: {
|
|
detail: true,
|
|
_count: { select: { variants: true } },
|
|
family: { select: { id: true, familyName: true, familyCode: true, stale: true } },
|
|
},
|
|
}),
|
|
this.prisma.good.groupBy({
|
|
by: ['originGoodId'],
|
|
_count: { _all: true },
|
|
}),
|
|
this.prisma.good.findMany({
|
|
select: {
|
|
originGoodId: true,
|
|
country: { select: { countryName: true } },
|
|
},
|
|
distinct: ['originGoodId', 'countryId'],
|
|
}),
|
|
this.prisma.goodTag.findMany({
|
|
select: {
|
|
good: {
|
|
select: {
|
|
originGoodId: true,
|
|
mergedOriginGoods: { select: { originGoodId: true } },
|
|
},
|
|
},
|
|
tag: {
|
|
select: {
|
|
tagName: true,
|
|
tagColor: true,
|
|
tagFontColor: true,
|
|
tagGroupId: true,
|
|
sortOrder: true,
|
|
tagGroup: { select: { groupName: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
// Secondary-source references (good_origin_goods)
|
|
this.prisma.goodOriginGood.groupBy({
|
|
by: ['originGoodId'],
|
|
_count: { _all: true },
|
|
}),
|
|
this.prisma.goodOriginGood.findMany({
|
|
select: {
|
|
originGoodId: true,
|
|
good: { select: { country: { select: { countryName: true } } } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const countMap = new Map<string, number>();
|
|
configCounts.forEach((c) =>
|
|
countMap.set(c.originGoodId.toString(), c._count._all),
|
|
);
|
|
// Secondary (merged) references count towards configured status too.
|
|
mergedCounts.forEach((c) => {
|
|
const key = c.originGoodId.toString();
|
|
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
|
|
});
|
|
|
|
const countryMap = new Map<string, string[]>();
|
|
const addCountry = (key: string, name?: string | null) => {
|
|
if (!name) return;
|
|
const arr = countryMap.get(key);
|
|
if (!arr?.includes(name)) {
|
|
countryMap.set(key, [...(arr ?? []), name]);
|
|
}
|
|
};
|
|
goodsWithCountries.forEach((g) =>
|
|
addCountry(g.originGoodId.toString(), g.country?.countryName),
|
|
);
|
|
mergedWithCountries.forEach((m) =>
|
|
addCountry(m.originGoodId.toString(), m.good.country?.countryName),
|
|
);
|
|
|
|
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
|
const addTag = (
|
|
key: string,
|
|
tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number },
|
|
) => {
|
|
const arr = tagMap.get(key);
|
|
if (arr) {
|
|
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
|
} else {
|
|
tagMap.set(key, [tagInfo]);
|
|
}
|
|
};
|
|
goodsWithTags.forEach((gt) => {
|
|
const tagInfo = {
|
|
tagName: gt.tag.tagName,
|
|
tagColor: gt.tag.tagColor,
|
|
tagFontColor: gt.tag.tagFontColor,
|
|
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
|
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
|
sortOrder: gt.tag.sortOrder,
|
|
};
|
|
addTag(gt.good.originGoodId.toString(), tagInfo);
|
|
// A good's tags also mark its secondary origin goods as configured.
|
|
for (const m of gt.good.mergedOriginGoods) {
|
|
addTag(m.originGoodId.toString(), tagInfo);
|
|
}
|
|
});
|
|
|
|
const sdsToCategory = new Map<
|
|
string,
|
|
(typeof allCategories)[number]
|
|
>();
|
|
for (const c of allCategories) {
|
|
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
|
}
|
|
|
|
const ogToCategory = new Map<string, string>();
|
|
for (const og of allOriginGoods) {
|
|
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
|
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
|
}
|
|
}
|
|
|
|
const buildNode = (
|
|
cat: (typeof allCategories)[number],
|
|
): OriginGoodsTreeCategoryNode => {
|
|
const childrenCats = allCategories.filter(
|
|
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
|
);
|
|
const childNodes = childrenCats
|
|
.map(buildNode)
|
|
.filter((n) => n.totalCount > 0);
|
|
|
|
const ogsForThisCat = allOriginGoods.filter(
|
|
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
|
);
|
|
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
|
id: og.id.toString(),
|
|
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
|
goodImage: og.goodImage,
|
|
goodPrice: og.goodPrice?.toString() ?? null,
|
|
sdsGoodId: og.sdsGoodId,
|
|
delisted: og.delisted,
|
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
|
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
|
hasDetail: Boolean(og.detail),
|
|
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
|
variantCount: og._count.variants,
|
|
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
|
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
|
familyId: og.family?.id.toString() ?? null,
|
|
familyName: og.family?.familyName ?? null,
|
|
familyCode: og.family?.familyCode ?? null,
|
|
familyStale: og.family?.stale ?? null,
|
|
}));
|
|
|
|
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
|
const childConfigured = childNodes.reduce(
|
|
(s, n) => s + n.configuredCount,
|
|
0,
|
|
);
|
|
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
|
|
|
return {
|
|
categoryId: cat.id.toString(),
|
|
categoryName: cat.categoryName,
|
|
sdsCategoryId: cat.sdsCategoryId,
|
|
configuredCount: childConfigured + ogConfigured,
|
|
totalCount: childTotal + ogNodes.length,
|
|
children: childNodes,
|
|
originGoods: ogNodes,
|
|
};
|
|
};
|
|
|
|
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
|
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
|
|
|
const unmapped = allOriginGoods.filter(
|
|
(og) => !ogToCategory.has(og.id.toString()),
|
|
);
|
|
if (unmapped.length > 0) {
|
|
tree.push({
|
|
categoryId: 'uncategorized',
|
|
categoryName: '未分类',
|
|
sdsCategoryId: null,
|
|
configuredCount: unmapped.filter(
|
|
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
|
).length,
|
|
totalCount: unmapped.length,
|
|
children: [],
|
|
originGoods: unmapped.map((og) => ({
|
|
id: og.id.toString(),
|
|
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
|
goodImage: og.goodImage,
|
|
goodPrice: og.goodPrice?.toString() ?? null,
|
|
sdsGoodId: og.sdsGoodId,
|
|
delisted: og.delisted,
|
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
|
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
|
hasDetail: Boolean(og.detail),
|
|
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
|
variantCount: og._count.variants,
|
|
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
|
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
|
familyId: og.family?.id.toString() ?? null,
|
|
familyName: og.family?.familyName ?? null,
|
|
familyCode: og.family?.familyCode ?? null,
|
|
familyStale: og.family?.stale ?? null,
|
|
})),
|
|
});
|
|
}
|
|
|
|
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
|
|
|
const totalConfigured = allOriginGoods.filter(
|
|
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
|
).length;
|
|
|
|
return {
|
|
tree,
|
|
totalOriginGoods: allOriginGoods.length,
|
|
configuredCount: totalConfigured,
|
|
};
|
|
}
|
|
|
|
private jsonRows(value: unknown): number {
|
|
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
|
const rows = (value as { rows?: unknown }).rows;
|
|
return Array.isArray(rows) ? rows.length : 0;
|
|
}
|
|
}
|