feat(goods): add editable custom products
This commit is contained in:
@@ -302,7 +302,7 @@ describe('SyncService product detail scopes', () => {
|
||||
const result = await scopedService.syncAllProductDetails();
|
||||
|
||||
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { delisted: false } }),
|
||||
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
|
||||
);
|
||||
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
|
||||
@@ -314,8 +314,29 @@ describe('SyncService product detail scopes', () => {
|
||||
|
||||
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { delisted: false, goods: { some: {} } },
|
||||
where: { delisted: false, source: 'SDS', goods: { some: {} } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
|
||||
const { scopedService } = createService();
|
||||
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
|
||||
inserted: 0, updated: 0, total: 0, deletedStale: 0,
|
||||
});
|
||||
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
|
||||
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
|
||||
});
|
||||
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
|
||||
total: 0, synced: 0, failed: 0,
|
||||
});
|
||||
|
||||
await scopedService.hourlyCron();
|
||||
expect(categories).toHaveBeenCalledTimes(1);
|
||||
expect(products).toHaveBeenCalledTimes(1);
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
|
||||
await scopedService.dailyProductDetailCron();
|
||||
expect(details).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -23,8 +28,6 @@ export interface ProductSyncResult {
|
||||
total: number;
|
||||
leafCategories: number;
|
||||
delisted: number;
|
||||
detailsSynced: number;
|
||||
detailFailures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +113,16 @@ export class SyncService {
|
||||
return { message: 'Product sync started' };
|
||||
}
|
||||
|
||||
/** Refresh all active SDS product details once per day at 03:30. */
|
||||
@Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' })
|
||||
async dailyProductDetailCron(): Promise<void> {
|
||||
try {
|
||||
await this.syncProductDetails();
|
||||
} catch (err) {
|
||||
this.logger.error('Daily product detail sync failed', err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async startProductDetailSync(): Promise<{ message: string }> {
|
||||
if (this.running.details) {
|
||||
return { message: 'Product detail sync already in progress' };
|
||||
@@ -341,11 +354,19 @@ export class SyncService {
|
||||
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
|
||||
if (runDelist) {
|
||||
const delistedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
|
||||
where: {
|
||||
source: 'SDS',
|
||||
sdsGoodId: { notIn: [...seenSdsGoodIds] },
|
||||
delisted: false,
|
||||
},
|
||||
data: { delisted: true },
|
||||
});
|
||||
const reactivatedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
|
||||
where: {
|
||||
source: 'SDS',
|
||||
sdsGoodId: { in: [...seenSdsGoodIds] },
|
||||
delisted: true,
|
||||
},
|
||||
data: { delisted: false },
|
||||
});
|
||||
delistedCount = delistedResult.count;
|
||||
@@ -357,17 +378,12 @@ export class SyncService {
|
||||
);
|
||||
}
|
||||
|
||||
// Only hydrate full details for products selected in the website catalog.
|
||||
// This keeps the hourly sync bounded and preserves the existing Good/tag
|
||||
// merchandising model. A failed detail request never erases cached data.
|
||||
const detailResult = await this.syncConfiguredProductDetails();
|
||||
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -376,8 +392,6 @@ export class SyncService {
|
||||
total,
|
||||
leafCategories: leafRows.length,
|
||||
delisted: delistedCount,
|
||||
detailsSynced: detailResult.synced,
|
||||
detailFailures: detailResult.failed,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -451,11 +465,14 @@ export class SyncService {
|
||||
}> {
|
||||
const originGood = await this.prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: goodId },
|
||||
select: { id: true },
|
||||
select: { id: true, source: true },
|
||||
});
|
||||
if (!originGood) {
|
||||
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
||||
}
|
||||
if (originGood.source !== 'SDS') {
|
||||
throw new BadRequestException('自定义商品不支持从 SDS 同步详情');
|
||||
}
|
||||
const upstream = await this.sds.fetchProductDetail(goodId);
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
@@ -476,6 +493,7 @@ export class SyncService {
|
||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||
const result = await this.syncMatchingProductDetails({
|
||||
delisted: false,
|
||||
source: 'SDS',
|
||||
goods: { some: {} },
|
||||
});
|
||||
return { synced: result.synced, failed: result.failed };
|
||||
@@ -490,7 +508,7 @@ export class SyncService {
|
||||
}) => Promise<void>,
|
||||
): Promise<{ total: number; synced: number; failed: number }> {
|
||||
return this.syncMatchingProductDetails(
|
||||
{ delisted: false },
|
||||
{ delisted: false, source: 'SDS' },
|
||||
onProgress,
|
||||
2,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user