feat(admin): manage and sync product details
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
|
||||
@@ -248,6 +248,7 @@ model User {
|
||||
enum SyncType {
|
||||
CATEGORIES
|
||||
PRODUCTS
|
||||
PRODUCT_DETAILS
|
||||
}
|
||||
|
||||
enum SyncStatus {
|
||||
|
||||
@@ -12,6 +12,24 @@ export interface GoodRelations {
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
detail?: {
|
||||
productCode: string | null;
|
||||
syncedAt: Date;
|
||||
sizeChart: unknown;
|
||||
packageSpecs: unknown;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
variants?: Array<{
|
||||
sdsVariantId: string;
|
||||
sku: string;
|
||||
sizeName: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
price: unknown;
|
||||
enabled: boolean;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
_count?: { variants: number };
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
@@ -72,6 +90,12 @@ export class GoodDto {
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
productCode: string | null;
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
@@ -137,10 +161,46 @@ export class GoodDto {
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||
hasDetail: Boolean(rel.originGood.detail),
|
||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||
productCode: rel.originGood.detail?.productCode ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static 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;
|
||||
}
|
||||
}
|
||||
|
||||
export class GoodDetailDto extends GoodDto {
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
originDetail!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
variants!: Array<Record<string, unknown>>;
|
||||
|
||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||
const base = GoodDto.from(good, rel);
|
||||
const detail = rel.originGood?.detail;
|
||||
return {
|
||||
...base,
|
||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
||||
...variant,
|
||||
price:
|
||||
variant.price === null || variant.price === undefined
|
||||
? null
|
||||
: (variant.price as { toString(): string }).toString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedGoods {
|
||||
@@ -148,4 +208,4 @@ export interface PaginatedGoods {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,30 @@ export class GoodsController {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one good with relations' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one good with relations' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a good' })
|
||||
update(
|
||||
@@ -62,16 +74,4 @@ export class GoodsController {
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
@@ -22,7 +23,14 @@ describe('GoodsService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [GoodsService, PrismaService],
|
||||
providers: [
|
||||
GoodsService,
|
||||
PrismaService,
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
|
||||
@@ -10,20 +10,30 @@ import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
@@ -65,13 +75,13 @@ export class GoodsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: bigint): Promise<GoodDto> {
|
||||
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return GoodDto.from(good, {
|
||||
return GoodDetailDto.fromGood(good, {
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
@@ -83,7 +93,7 @@ export class GoodsService {
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
@@ -116,6 +126,10 @@ export class GoodsService {
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
@@ -148,7 +162,7 @@ export class GoodsService {
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
@@ -174,6 +188,10 @@ export class GoodsService {
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async remove(id: bigint): Promise<{ id: string }> {
|
||||
@@ -187,7 +205,7 @@ export class GoodsService {
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
@@ -196,6 +214,7 @@ export class GoodsService {
|
||||
}
|
||||
return { count: dto.items.length };
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +228,7 @@ export class GoodsService {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
@@ -254,6 +273,15 @@ export class GoodsService {
|
||||
}
|
||||
return created;
|
||||
});
|
||||
for (const goodId of new Set(
|
||||
result
|
||||
.filter((item) => !item.originGood?.hasDetail)
|
||||
.map((item) => item.originGood?.sdsGoodId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
)) {
|
||||
this.syncService.queueProductDetailSync(goodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,4 +341,4 @@ export class GoodsService {
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface PaginatedOriginGoods {
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
@@ -33,6 +36,11 @@ export interface OriginGoodsTreeNode {
|
||||
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. */
|
||||
@@ -68,6 +76,7 @@ export class OriginGoodsService {
|
||||
this.prisma.originGood.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
@@ -83,6 +92,9 @@ export class OriginGoodsService {
|
||||
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,
|
||||
@@ -111,7 +123,11 @@ export class OriginGoodsService {
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false },
|
||||
orderBy: { goodName: 'asc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
}),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
@@ -212,6 +228,11 @@ export class OriginGoodsService {
|
||||
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);
|
||||
@@ -258,6 +279,11 @@ export class OriginGoodsService {
|
||||
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),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -274,4 +300,10 @@ export class OriginGoodsService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,27 +53,6 @@ export class PublicQueryGoodDto {
|
||||
@IsNumberString({}, { each: true })
|
||||
tagIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], description: '印刷工艺标签 ID' })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsNumberString({}, { each: true })
|
||||
craftIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, type: [String], description: '材质标签 ID' })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsNumberString({}, { each: true })
|
||||
materialIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false, enum: ['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], isArray: true })
|
||||
@IsOptional()
|
||||
@Transform(stringList)
|
||||
@IsArray()
|
||||
@IsIn(['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], { each: true })
|
||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -145,14 +145,9 @@ export class PublicService {
|
||||
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||
}
|
||||
|
||||
const tagIds = [
|
||||
...new Set([
|
||||
...(query.tagIds ?? []),
|
||||
...(query.craftIds ?? []),
|
||||
...(query.materialIds ?? []),
|
||||
]),
|
||||
];
|
||||
const tagFilters = await this.buildTagGroupFilters(tagIds, query.freeShipping);
|
||||
const tagFilters = await this.buildTagGroupFilters([
|
||||
...new Set(query.tagIds ?? []),
|
||||
]);
|
||||
if (tagFilters.length) where.AND = tagFilters;
|
||||
|
||||
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
||||
@@ -325,7 +320,6 @@ export class PublicService {
|
||||
|
||||
private async buildTagGroupFilters(
|
||||
selectedTagIds: string[],
|
||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>,
|
||||
): Promise<Prisma.GoodWhereInput[]> {
|
||||
const selected = selectedTagIds.length
|
||||
? await this.prisma.tag.findMany({
|
||||
@@ -336,19 +330,6 @@ export class PublicService {
|
||||
if (selected.length !== selectedTagIds.length) {
|
||||
throw new BadRequestException('包含不存在的标签 ID');
|
||||
}
|
||||
if (freeShipping?.length) {
|
||||
const names = freeShipping.map((value) =>
|
||||
value === 'FREE_SHIPPING' ? '包邮' : '不包邮',
|
||||
);
|
||||
const shippingTags = await this.prisma.tag.findMany({
|
||||
where: { tagName: { in: names }, tagGroup: { groupName: '物流渠道' } },
|
||||
select: { id: true, tagGroupId: true },
|
||||
});
|
||||
if (shippingTags.length !== new Set(names).size) {
|
||||
throw new BadRequestException('物流渠道标签配置不完整');
|
||||
}
|
||||
selected.push(...shippingTags);
|
||||
}
|
||||
const byGroup = new Map<string, bigint[]>();
|
||||
for (const tag of selected) {
|
||||
const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`;
|
||||
|
||||
@@ -6,7 +6,7 @@ export class SyncLogDto {
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
type!: 'CATEGORIES' | 'PRODUCTS';
|
||||
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
@@ -36,6 +37,18 @@ export class SyncController {
|
||||
return this.service.startProductSync();
|
||||
}
|
||||
|
||||
@Post('product-details')
|
||||
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' })
|
||||
async syncProductDetails() {
|
||||
return this.service.startProductDetailSync();
|
||||
}
|
||||
|
||||
@Post('products/:goodId/detail')
|
||||
@ApiOperation({ summary: 'Immediately sync one SDS product detail' })
|
||||
async syncOneProductDetail(@Param('goodId') goodId: string) {
|
||||
return this.service.syncOneProductDetail(goodId);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -67,7 +67,7 @@ export function shouldRunDelistDetection(leafCategories: number, seenGoods: numb
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private running = { categories: false, products: false };
|
||||
private running = { categories: false, products: false, details: false };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -110,8 +110,18 @@ export class SyncService {
|
||||
return { message: 'Product sync started' };
|
||||
}
|
||||
|
||||
async startProductDetailSync(): Promise<{ message: string }> {
|
||||
if (this.running.details) {
|
||||
return { message: 'Product detail sync already in progress' };
|
||||
}
|
||||
void this.syncProductDetails().catch((err) =>
|
||||
this.logger.error('Product detail sync failed', err as Error),
|
||||
);
|
||||
return { message: 'Product detail sync started' };
|
||||
}
|
||||
|
||||
/** Check if a sync type is currently running. */
|
||||
isRunning(type: 'categories' | 'products'): boolean {
|
||||
isRunning(type: 'categories' | 'products' | 'details'): boolean {
|
||||
return this.running[type];
|
||||
}
|
||||
|
||||
@@ -392,6 +402,66 @@ export class SyncService {
|
||||
});
|
||||
}
|
||||
|
||||
async syncProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||
if (this.running.details) {
|
||||
throw new Error('Product detail sync already in progress');
|
||||
}
|
||||
this.running.details = true;
|
||||
const log = await this.prisma.syncLog.create({
|
||||
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
||||
});
|
||||
try {
|
||||
const result = await this.syncConfiguredProductDetails();
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `synced=${result.synced} failed=${result.failed}`,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: { status: 'FAILED', finishedAt: new Date(), message },
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
this.running.details = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncOneProductDetail(goodId: string): Promise<{
|
||||
goodId: string;
|
||||
variants: number;
|
||||
detailSyncedAt: string;
|
||||
}> {
|
||||
const originGood = await this.prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: goodId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!originGood) {
|
||||
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
||||
}
|
||||
const upstream = await this.sds.fetchProductDetail(goodId);
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
return {
|
||||
goodId,
|
||||
variants: normalized.variants.length,
|
||||
detailSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
queueProductDetailSync(goodId: string): void {
|
||||
void this.syncOneProductDetail(goodId).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||
const configured = await this.prisma.originGood.findMany({
|
||||
where: { delisted: false, goods: { some: {} } },
|
||||
|
||||
Reference in New Issue
Block a user