fix(sync): hydrate all origin product details
This commit is contained in:
@@ -28,7 +28,7 @@ async function refreshLogs() {
|
|||||||
|
|
||||||
function syncTypeLabel(type: SyncType): string {
|
function syncTypeLabel(type: SyncType): string {
|
||||||
if (type === 'CATEGORIES') return '分类'
|
if (type === 'CATEGORIES') return '分类'
|
||||||
if (type === 'PRODUCT_DETAILS') return '商品详情'
|
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||||
return '商品列表'
|
return '商品列表'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
|
|||||||
const label = syncTypeLabel(type)
|
const label = syncTypeLabel(type)
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
@@ -166,7 +166,7 @@ onUnmounted(() => {
|
|||||||
:icon="Refresh"
|
:icon="Refresh"
|
||||||
@click="handleSyncProductDetails"
|
@click="handleSyncProductDetails"
|
||||||
>
|
>
|
||||||
同步商品详情
|
同步全部原产品详情
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class SyncController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('product-details')
|
@Post('product-details')
|
||||||
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' })
|
@ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
|
||||||
async syncProductDetails() {
|
async syncProductDetails() {
|
||||||
return this.service.startProductDetailSync();
|
return this.service.startProductDetailSync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,3 +276,46 @@ describe('SyncService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SyncService product detail scopes', () => {
|
||||||
|
const originGoods = [
|
||||||
|
{ id: 1n, sdsGoodId: 'all-1' },
|
||||||
|
{ id: 2n, sdsGoodId: 'all-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const prisma = {
|
||||||
|
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
const sds = {
|
||||||
|
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
||||||
|
} as unknown as SdsClientService;
|
||||||
|
const scopedService = new SyncService(prisma, sds);
|
||||||
|
jest
|
||||||
|
.spyOn(scopedService as any, 'persistProductDetail')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
return { scopedService, prisma, sds };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('manual detail sync selects every active origin product', async () => {
|
||||||
|
const { scopedService, prisma, sds } = createService();
|
||||||
|
const result = await scopedService.syncAllProductDetails();
|
||||||
|
|
||||||
|
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { delisted: false } }),
|
||||||
|
);
|
||||||
|
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hourly detail refresh remains limited to configured products', async () => {
|
||||||
|
const { scopedService, prisma } = createService();
|
||||||
|
await scopedService.syncConfiguredProductDetails();
|
||||||
|
|
||||||
|
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { delisted: false, goods: { some: {} } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -402,7 +402,11 @@ export class SyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncProductDetails(): Promise<{ synced: number; failed: number }> {
|
async syncProductDetails(): Promise<{
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}> {
|
||||||
if (this.running.details) {
|
if (this.running.details) {
|
||||||
throw new Error('Product detail sync already in progress');
|
throw new Error('Product detail sync already in progress');
|
||||||
}
|
}
|
||||||
@@ -411,13 +415,20 @@ export class SyncService {
|
|||||||
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await this.syncConfiguredProductDetails();
|
const result = await this.syncAllProductDetails(async (progress) => {
|
||||||
|
await this.prisma.syncLog.update({
|
||||||
|
where: { id: log.id },
|
||||||
|
data: {
|
||||||
|
message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `synced=${result.synced} failed=${result.failed}`,
|
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -463,25 +474,71 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||||
const configured = await this.prisma.originGood.findMany({
|
const result = await this.syncMatchingProductDetails({
|
||||||
where: { delisted: false, goods: { some: {} } },
|
delisted: false,
|
||||||
|
goods: { some: {} },
|
||||||
|
});
|
||||||
|
return { synced: result.synced, failed: result.failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAllProductDetails(
|
||||||
|
onProgress?: (progress: {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}) => Promise<void>,
|
||||||
|
): Promise<{ total: number; synced: number; failed: number }> {
|
||||||
|
return this.syncMatchingProductDetails(
|
||||||
|
{ delisted: false },
|
||||||
|
onProgress,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncMatchingProductDetails(
|
||||||
|
where: Prisma.OriginGoodWhereInput,
|
||||||
|
onProgress?: (progress: {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}) => Promise<void>,
|
||||||
|
attempts = 1,
|
||||||
|
): Promise<{ total: number; synced: number; failed: number }> {
|
||||||
|
const originGoods = await this.prisma.originGood.findMany({
|
||||||
|
where,
|
||||||
select: { id: true, sdsGoodId: true },
|
select: { id: true, sdsGoodId: true },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const originGood of configured) {
|
let processed = 0;
|
||||||
try {
|
for (const originGood of originGoods) {
|
||||||
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
let lastError: unknown;
|
||||||
await this.persistProductDetail(originGood.id, upstream);
|
let succeeded = false;
|
||||||
synced++;
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
} catch (error) {
|
try {
|
||||||
|
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
||||||
|
await this.persistProductDetail(originGood.id, upstream);
|
||||||
|
synced++;
|
||||||
|
succeeded = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!succeeded) {
|
||||||
failed++;
|
failed++;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
|
if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) {
|
||||||
|
await onProgress({ processed, total: originGoods.length, synced, failed });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { synced, failed };
|
return { total: originGoods.length, synced, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
||||||
|
|||||||
Reference in New Issue
Block a user