Files
inkreach-official-website/apps/api/src/origin-goods/origin-goods.service.ts
T
yeuimu 93ac525c55 refactor(api): parsing out of runtime — pure-mirror sync, explicit organize, auto aggregate recompute
解析去运行时化(三层架构,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
2026-08-30 01:27:01 +08:00

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;
}
}