feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
@@ -1,31 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryOriginGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false, description: 'Search by goodName' })
@IsOptional()
@IsString()
keyword?: string;
}
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryOriginGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false, description: 'Search by goodName' })
@IsOptional()
@IsString()
keyword?: string;
}
@@ -1,27 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
@ApiTags('origin-goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('origin-goods')
export class OriginGoodsController {
constructor(private readonly service: OriginGoodsService) {}
@Get('tree')
@ApiOperation({
summary: 'Origin goods grouped by SDS category with config status',
})
getTree() {
return this.service.getTree();
}
@Get()
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query);
}
}
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
@ApiTags('origin-goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('origin-goods')
export class OriginGoodsController {
constructor(private readonly service: OriginGoodsService) {}
@Get('tree')
@ApiOperation({
summary: 'Origin goods grouped by SDS category with config status',
})
getTree() {
return this.service.getTree();
}
@Get()
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query);
}
}
@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service';
@Module({
controllers: [OriginGoodsController],
providers: [OriginGoodsService],
})
export class OriginGoodsModule {}
import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service';
@Module({
controllers: [OriginGoodsController],
providers: [OriginGoodsService],
})
export class OriginGoodsModule {}
@@ -1,80 +1,80 @@
import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service';
import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
let service: OriginGoodsService;
let prisma: PrismaService;
const stamp = Date.now();
const createdSds: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, PrismaService],
}).compile();
service = moduleRef.get(OriginGoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
// Seed 25 rows with sequential goodNames so we can paginate/filter.
const rows = Array.from({ length: 25 }).map((_, i) => ({
sdsGoodId: `sds-${stamp}-${i}`,
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
sdsCategoryId: `cat-${stamp}-${i % 3}`,
}));
await prisma.originGood.createMany({ data: rows });
createdSds.push(...rows.map((r) => r.sdsGoodId));
});
afterAll(async () => {
if (createdSds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('returns paginated results', async () => {
const page1 = await service.findAll({
page: 1,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page1.total).toBe(25);
expect(page1.items.length).toBe(10);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
const page3 = await service.findAll({
page: 3,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page3.items.length).toBe(5);
});
it('searches by keyword (case insensitive)', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: `origin good ${stamp} 05`,
});
expect(result.items.length).toBe(1);
expect(result.items[0].goodName).toContain('05');
});
it('returns empty page when no matches', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: 'definitely-does-not-exist',
});
expect(result.total).toBe(0);
expect(result.items.length).toBe(0);
});
});
import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service';
import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
let service: OriginGoodsService;
let prisma: PrismaService;
const stamp = Date.now();
const createdSds: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, PrismaService],
}).compile();
service = moduleRef.get(OriginGoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
// Seed 25 rows with sequential goodNames so we can paginate/filter.
const rows = Array.from({ length: 25 }).map((_, i) => ({
sdsGoodId: `sds-${stamp}-${i}`,
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
sdsCategoryId: `cat-${stamp}-${i % 3}`,
}));
await prisma.originGood.createMany({ data: rows });
createdSds.push(...rows.map((r) => r.sdsGoodId));
});
afterAll(async () => {
if (createdSds.length) {
await prisma.originGood.deleteMany({
where: { sdsGoodId: { in: createdSds } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('returns paginated results', async () => {
const page1 = await service.findAll({
page: 1,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page1.total).toBe(25);
expect(page1.items.length).toBe(10);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
const page3 = await service.findAll({
page: 3,
pageSize: 10,
keyword: `Origin Good ${stamp}`,
});
expect(page3.items.length).toBe(5);
});
it('searches by keyword (case insensitive)', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: `origin good ${stamp} 05`,
});
expect(result.items.length).toBe(1);
expect(result.items[0].goodName).toContain('05');
});
it('returns empty page when no matches', async () => {
const result = await service.findAll({
page: 1,
pageSize: 5,
keyword: 'definitely-does-not-exist',
});
expect(result.total).toBe(0);
expect(result.items.length).toBe(0);
});
});
+261 -261
View File
@@ -1,306 +1,306 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
export interface PaginatedOriginGoods {
items: Array<{
id: string;
sdsGoodId: string;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
sdsCategoryId: string | null;
createdAt: string;
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
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[];
}>;
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;
}
/** 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) {}
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query;
}
/** 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) {}
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 }),
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(),
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] =
await Promise.all([
this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
orderBy: { categoryName: 'asc' },
select: {
id: true,
categoryName: true,
sdsCategoryId: true,
parentCategoryId: true,
},
}),
})),
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] =
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 } } },
}),
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 } },
tag: {
select: {
tagName: true,
tagColor: true,
tagFontColor: true,
tagGroupId: true,
sortOrder: true,
tagGroup: { select: { groupName: true } },
},
},
},
}),
]);
const countMap = new Map<string, number>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
});
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
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,
};
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [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()) ?? [],
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 } },
tag: {
select: {
tagName: true,
tagColor: true,
tagFontColor: true,
tagGroupId: true,
sortOrder: true,
tagGroup: { select: { groupName: true } },
},
},
},
}),
]);
const countMap = new Map<string, number>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
});
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
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,
};
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [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),
}));
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()) ?? [],
}));
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),
})),
});
}
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
const totalConfigured = allOriginGoods.filter(
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length;
})),
});
}
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,
tree,
totalOriginGoods: allOriginGoods.length,
configuredCount: totalConfigured,
};
}