import { ProductSiteSku } from '../entity/product_site_sku.entity'; import { Product } from '../entity/product.entity'; import { Inject, Provide } from '@midwayjs/core'; import * as fs from 'fs'; import { parse } from 'csv-parse'; import { WPService } from './wp.service'; import { WpProduct } from '../entity/wp_product.entity'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { And, Like, Not, Repository, In } from 'typeorm'; import { Variation } from '../entity/variation.entity'; import { QueryWpProductDTO, UpdateVariationDTO, UpdateWpProductDTO, BatchUpdateProductsDTO, } from '../dto/wp_product.dto'; import { ProductStatus, ProductStockStatus } from '../enums/base.enum'; import { SiteService } from './site.service'; import { StockService } from './stock.service'; @Provide() export class WpProductService { // 移除配置中的站点数组,统一从数据库获取站点信息 @Inject() private readonly wpApiService: WPService; @Inject() private readonly siteService: SiteService; @Inject() private readonly stockService: StockService; @InjectEntityModel(WpProduct) wpProductModel: Repository; @InjectEntityModel(Variation) variationModel: Repository; @InjectEntityModel(Product) productModel: Repository; @InjectEntityModel(ProductSiteSku) productSiteSkuModel: Repository; async syncAllSites() { // 从数据库获取所有启用的站点,并逐站点同步产品与变体 const { items: sites } = await this.siteService.list({ current: 1, pageSize: Infinity, isDisabled: false }, true); for (const site of sites) { const products = await this.wpApiService.getProducts(site); for (const product of products) { const variations = product.type === 'variable' ? await this.wpApiService.getVariations(site, product.id) : []; await this.syncProductAndVariations(site.id, product, variations); } } } private logToFile(msg: string, data?: any) { const logFile = '/Users/zksu/Developer/work/workcode/API/debug_sync.log'; const timestamp = new Date().toISOString(); let content = `[${timestamp}] ${msg}`; if (data !== undefined) { content += ' ' + (typeof data === 'object' ? JSON.stringify(data) : String(data)); } content += '\n'; try { fs.appendFileSync(logFile, content); } catch (e) { console.error('Failed to write to log file:', e); } console.log(msg, data || ''); } async batchSyncToSite(siteId: number, productIds: number[]) { this.logToFile(`[BatchSync] Starting sync to site ${siteId} for products:`, productIds); const site = await this.siteService.get(siteId, true); const products = await this.productModel.find({ where: { id: In(productIds) }, }); this.logToFile(`[BatchSync] Found ${products.length} products in local DB`); const batchData = { create: [], update: [], }; const skuToProductMap = new Map(); for (const product of products) { const targetSku = (site.skuPrefix || '') + product.sku; skuToProductMap.set(targetSku, product); // Determine if we should create or update based on local WpProduct record const existingWpProduct = await this.wpProductModel.findOne({ where: { siteId, sku: targetSku, on_delete: false } }); const productData = { name: product.name, type: product.type === 'single' ? 'simple' : (product.type === 'bundle' ? 'bundle' : 'simple'), regular_price: product.price ? String(product.price) : '0', sale_price: product.promotionPrice ? String(product.promotionPrice) : '', sku: targetSku, status: 'publish', // Default to publish // categories? }; if (existingWpProduct) { batchData.update.push({ id: existingWpProduct.externalProductId, ...productData }); } else { batchData.create.push(productData); } } this.logToFile(`[BatchSync] Payload - Create: ${batchData.create.length}, Update: ${batchData.update.length}`); if (batchData.create.length > 0) this.logToFile('[BatchSync] Create Payload:', JSON.stringify(batchData.create)); if (batchData.update.length > 0) this.logToFile('[BatchSync] Update Payload:', JSON.stringify(batchData.update)); if (batchData.create.length === 0 && batchData.update.length === 0) { this.logToFile('[BatchSync] No actions needed, skipping API call'); return; } let result; try { result = await this.wpApiService.batchProcessProducts(site, batchData); this.logToFile('[BatchSync] API Success. Result:', JSON.stringify(result)); } catch (error) { this.logToFile('[BatchSync] API Error:', error); throw error; } // Process results to update local WpProduct and ProductSiteSku const processResultItem = async (item: any, sourceList: any[], index: number) => { const originalSku = sourceList[index]?.sku; if (item.id) { this.logToFile(`[BatchSync] Processing success item: ID=${item.id}, SKU=${item.sku}`); let localProduct = skuToProductMap.get(item.sku); // Fallback to original SKU if response SKU differs or lookup fails if (!localProduct && originalSku) { localProduct = skuToProductMap.get(originalSku); } if (localProduct) { this.logToFile(`[BatchSync] Found local product ID=${localProduct.id} for SKU=${item.sku || originalSku}`); const code = item.sku || originalSku; const existingSiteSku = await this.productSiteSkuModel.findOne({ where: { productId: localProduct.id, siteSku: code }, }); if (!existingSiteSku) { this.logToFile(`[BatchSync] Creating ProductSiteSku for productId=${localProduct.id} code=${code}`); await this.productSiteSkuModel.save({ productId: localProduct.id, siteSku: code, }); } else { this.logToFile(`[BatchSync] ProductSiteSku already exists for productId=${localProduct.id} code=${code}`); } } else { this.logToFile(`[BatchSync] Warning: Local product not found in map for SKU=${item.sku || originalSku}`); } // Sync back to local WpProduct table await this.syncProductAndVariations(siteId, item, []); } else if (item.error) { // Handle duplicated SKU error by linking to existing remote product if (item.error.code === 'product_invalid_sku' && item.error.data && item.error.data.resource_id) { const recoveredSku = item.error.data.unique_sku; const resourceId = item.error.data.resource_id; this.logToFile(`[BatchSync] Recovering from duplicate SKU error. SKU=${recoveredSku}, ID=${resourceId}`); let localProduct = skuToProductMap.get(recoveredSku); // Fallback to original SKU if (!localProduct && originalSku) { localProduct = skuToProductMap.get(originalSku); } if (localProduct) { // Construct a fake product object to sync local DB const fakeProduct = { id: resourceId, sku: recoveredSku, // Use the actual SKU on server name: localProduct.name, type: localProduct.type === 'single' ? 'simple' : (localProduct.type === 'bundle' ? 'bundle' : 'simple'), status: 'publish', regular_price: localProduct.price ? String(localProduct.price) : '0', sale_price: localProduct.promotionPrice ? String(localProduct.promotionPrice) : '', on_sale: !!localProduct.promotionPrice, metadata: [], tags: [], categories: [] }; try { await this.syncProductAndVariations(siteId, fakeProduct as any, []); this.logToFile(`[BatchSync] Successfully linked local product to existing remote product ID=${resourceId}`); } catch (e) { this.logToFile(`[BatchSync] Failed to link recovered product:`, e); } } else { this.logToFile(`[BatchSync] Warning: Local product not found in map for recovered SKU=${recoveredSku} or original SKU=${originalSku}`); } } else { this.logToFile(`[BatchSync] Item Error: SKU=${originalSku || 'unknown'}`, item.error); } } else { this.logToFile(`[BatchSync] Unknown item format:`, item); } }; if (result.create) { for (let i = 0; i < result.create.length; i++) { await processResultItem(result.create[i], batchData.create, i); } } if (result.update) { for (let i = 0; i < result.update.length; i++) { await processResultItem(result.update[i], batchData.update, i); } } return result; } async batchUpdateTags(ids: number[], tags: string[]) { if (!ids || ids.length === 0 || !tags || tags.length === 0) return; const products = await this.wpProductModel.find({ where: { id: In(ids) }, }); // Group by siteId const productsBySite = new Map(); for (const product of products) { if (!productsBySite.has(product.siteId)) { productsBySite.set(product.siteId, []); } productsBySite.get(product.siteId).push(product); } for (const [siteId, siteProducts] of productsBySite) { const site = await this.siteService.get(siteId, true); if (!site) continue; const batchData = { create: [], update: [], }; for (const product of siteProducts) { const currentTags = product.tags || []; // Add new tags, avoiding duplicates by name const newTags = [...currentTags]; const tagsToAdd = []; for (const tag of tags) { if (!newTags.some(t => t.name === tag)) { const newTagObj = { name: tag, id: 0, slug: '' }; newTags.push(newTagObj); tagsToAdd.push(newTagObj); } } if (tagsToAdd.length > 0) { batchData.update.push({ id: product.externalProductId, tags: newTags.map(t => (t.id ? { id: t.id } : { name: t.name })), }); // Update local DB optimistically // Generate slug simply tagsToAdd.forEach(t => (t.slug = t.name.toLowerCase().replace(/\s+/g, '-'))); product.tags = newTags; await this.wpProductModel.save(product); } } if (batchData.update.length > 0) { await this.wpApiService.batchProcessProducts(site, batchData); } } } async batchUpdateProducts(dto: BatchUpdateProductsDTO) { const { ids, ...updates } = dto; if (!ids || ids.length === 0) return; const products = await this.wpProductModel.find({ where: { id: In(ids) }, }); // Group by siteId const productsBySite = new Map(); for (const product of products) { if (!productsBySite.has(product.siteId)) { productsBySite.set(product.siteId, []); } productsBySite.get(product.siteId).push(product); } for (const [siteId, siteProducts] of productsBySite) { const site = await this.siteService.get(siteId, true); if (!site) continue; // Resolve Categories if needed let categoryIds: number[] = []; if (updates.categories && updates.categories.length > 0) { // 1. Get all existing categories const allCategories = await this.wpApiService.getCategories(site); const existingCatMap = new Map(allCategories.map(c => [c.name, c.id])); // 2. Identify missing categories const missingCategories = updates.categories.filter(name => !existingCatMap.has(name)); // 3. Create missing categories if (missingCategories.length > 0) { const createPayload = missingCategories.map(name => ({ name })); const createdCatsResult = await this.wpApiService.batchProcessCategories(site, { create: createPayload }); if (createdCatsResult && createdCatsResult.create) { createdCatsResult.create.forEach(c => { if (c.id && c.name) existingCatMap.set(c.name, c.id); }); } } // 4. Collect all IDs categoryIds = updates.categories .map(name => existingCatMap.get(name)) .filter(id => id !== undefined); } // Resolve Tags if needed let tagIds: number[] = []; if (updates.tags && updates.tags.length > 0) { // 1. Get all existing tags const allTags = await this.wpApiService.getTags(site); const existingTagMap = new Map(allTags.map(t => [t.name, t.id])); // 2. Identify missing tags const missingTags = updates.tags.filter(name => !existingTagMap.has(name)); // 3. Create missing tags if (missingTags.length > 0) { const createPayload = missingTags.map(name => ({ name })); const createdTagsResult = await this.wpApiService.batchProcessTags(site, { create: createPayload }); if (createdTagsResult && createdTagsResult.create) { createdTagsResult.create.forEach(t => { if (t.id && t.name) existingTagMap.set(t.name, t.id); }); } } // 4. Collect all IDs tagIds = updates.tags .map(name => existingTagMap.get(name)) .filter(id => id !== undefined); } const batchData = { create: [], update: [], }; for (const product of siteProducts) { const updateData: any = { id: product.externalProductId, }; if (updates.regular_price) updateData.regular_price = String(updates.regular_price); if (updates.sale_price) updateData.sale_price = String(updates.sale_price); if (updates.status) updateData.status = updates.status; if (categoryIds.length > 0) { updateData.categories = categoryIds.map(id => ({ id })); } if (tagIds.length > 0) { updateData.tags = tagIds.map(id => ({ id })); } batchData.update.push(updateData); // Optimistic update local DB if (updates.regular_price) product.regular_price = updates.regular_price; if (updates.sale_price) product.sale_price = updates.sale_price; if (updates.status) product.status = updates.status as ProductStatus; if (updates.categories) product.categories = updates.categories.map(c => ({ name: c, id: 0, slug: '' })); // simple mock if (updates.tags) product.tags = updates.tags.map(t => ({ name: t, id: 0, slug: '' })); // simple mock await this.wpProductModel.save(product); } if (batchData.update.length > 0) { await this.wpApiService.batchProcessProducts(site, batchData); } } } async importProducts(siteId: number, file: any) { const site = await this.siteService.get(siteId, true); if (!site) throw new Error('站点不存在'); const parser = fs .createReadStream(file.data) .pipe(parse({ columns: true, skip_empty_lines: true, trim: true, bom: true })); let batch = []; const batchSize = 50; for await (const record of parser) { batch.push(record); if (batch.length >= batchSize) { await this.processImportBatch(siteId, site, batch); batch = []; } } if (batch.length > 0) { await this.processImportBatch(siteId, site, batch); } } private async processImportBatch(siteId: number, site: any, chunk: any[]) { const batchData = { create: [], update: [], }; for (const row of chunk) { const sku = row['SKU'] || row['sku']; if (!sku) continue; const existingProduct = await this.wpProductModel.findOne({ where: { siteId, sku } }); const productData: any = { sku: sku, name: row['Name'] || row['name'], type: (row['Type'] || row['type'] || 'simple').toLowerCase(), regular_price: row['Regular price'] || row['regular_price'], sale_price: row['Sale price'] || row['sale_price'], short_description: row['Short description'] || row['short_description'] || '', description: row['Description'] || row['description'] || '', }; if (productData.regular_price) productData.regular_price = String(productData.regular_price); if (productData.sale_price) productData.sale_price = String(productData.sale_price); const imagesStr = row['Images'] || row['images']; if (imagesStr) { productData.images = imagesStr.split(',').map(url => ({ src: url.trim() })); } if (existingProduct) { batchData.update.push({ id: existingProduct.externalProductId, ...productData }); } else { batchData.create.push(productData); } } if (batchData.create.length > 0 || batchData.update.length > 0) { try { const result = await this.wpApiService.batchProcessProducts(site, batchData); await this.syncBackFromBatchResult(siteId, result); } catch (e) { console.error('Batch process error during import:', e); } } } private async syncBackFromBatchResult(siteId: number, result: any) { const processResultItem = async (item: any) => { if (item.id) { await this.syncProductAndVariations(siteId, item, []); } }; if (result.create) { for (const item of result.create) { await processResultItem(item); } } if (result.update) { for (const item of result.update) { await processResultItem(item); } } } // 同步产品库存到 Site async syncProductStockToSite(siteId: number, sku: string) { const site = await this.siteService.get(siteId, true); if (!site) throw new Error('站点不存在'); // 获取站点绑定的仓库 if (!site.stockPoints || site.stockPoints.length === 0) { console.log(`站点 ${siteId} 未绑定任何仓库,跳过库存同步`); return; } // 获取产品在这些仓库的总库存 const stockPointIds = site.stockPoints.map(sp => sp.id); const stock = await this.stockService.stockModel .createQueryBuilder('stock') .select('SUM(stock.quantity)', 'total') .where('stock.sku = :sku', { sku }) .andWhere('stock.stockPointId IN (:...stockPointIds)', { stockPointIds }) .getRawOne(); const quantity = stock && stock.total ? Number(stock.total) : 0; const stockStatus = quantity > 0 ? ProductStockStatus.INSTOCK : ProductStockStatus.OUT_OF_STOCK; // 查找对应的 WpProduct 以获取 externalProductId const wpProduct = await this.wpProductModel.findOne({ where: { siteId, sku } }); if (wpProduct) { // 更新 WooCommerce 库存 await this.wpApiService.updateProductStock(site, wpProduct.externalProductId, quantity, stockStatus); // 更新本地 WpProduct 状态 wpProduct.stock_quantity = quantity; wpProduct.stockStatus = stockStatus; await this.wpProductModel.save(wpProduct); } else { // 尝试查找变体 const variation = await this.variationModel.findOne({ where: { siteId, sku } }); if (variation) { await this.wpApiService.updateProductVariationStock(site, variation.externalProductId, variation.externalVariationId, quantity, stockStatus); // 变体表目前没有 stock_quantity 字段,如果需要可以添加 } } } // 同步一个网站 async syncSite(siteId: number) { try { // 通过数据库获取站点并转换为 Site,用于后续 WooCommerce 同步 const site = await this.siteService.get(siteId, true); const externalProductIds = this.wpProductModel.createQueryBuilder('wp_product') .select([ 'wp_product.id ', 'wp_product.externalProductId ', ]) .where('wp_product.siteId = :siteId', { siteId, }) const rawResult = await externalProductIds.getRawMany(); const externalIds = rawResult.map(item => item.externalProductId); const excludeValues = []; const products = await this.wpApiService.getProducts(site); let successCount = 0; let failureCount = 0; for (const product of products) { try { excludeValues.push(String(product.id)); const variations = product.type === 'variable' ? await this.wpApiService.getVariations(site, product.id) : []; await this.syncProductAndVariations(site.id, product, variations); successCount++; } catch (error) { console.error(`同步产品 ${product.id} 失败:`, error); failureCount++; } } const filteredIds = externalIds.filter(id => !excludeValues.includes(id)); if (filteredIds.length != 0) { await this.variationModel.createQueryBuilder('variation') .update() .set({ on_delete: true }) .where('variation.siteId = :siteId AND variation.externalProductId IN (:...filteredId)', { siteId, filteredId: filteredIds }) .execute(); this.wpProductModel.createQueryBuilder('wp_product') .update() .set({ on_delete: true }) .where('wp_product.siteId = :siteId AND wp_product.externalProductId IN (:...filteredId)', { siteId, filteredId: filteredIds }) .execute(); } return { success: failureCount === 0, successCount, failureCount, message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, }; } catch (error) { console.error('同步站点产品失败:', error); return { success: false, successCount: 0, failureCount: 0, message: `同步失败: ${error.message}` }; } } // 控制产品上下架 async updateProductStatus(id: number, status: ProductStatus, stock_status: ProductStockStatus) { const wpProduct = await this.wpProductModel.findOneBy({ id }); const site = await this.siteService.get(wpProduct.siteId, true); wpProduct.status = status; wpProduct.stockStatus = stock_status; const res = await this.wpApiService.updateProductStatus(site, wpProduct.externalProductId, status, stock_status); if (res === true) { this.wpProductModel.save(wpProduct); return true; } else { return res; } } async findProduct( siteId: number, externalProductId: string ): Promise { return await this.wpProductModel.findOne({ where: { siteId, externalProductId }, }); } async findVariation( siteId: number, externalProductId: string, externalVariationId: string ): Promise { return await this.variationModel.findOne({ where: { siteId, externalProductId, externalVariationId, on_delete: false }, }); } async updateWpProduct( siteId: number, productId: string, product: UpdateWpProductDTO ) { let existingProduct = await this.findProduct(siteId, productId); if (existingProduct) { if (product.name) existingProduct.name = product.name; if (product.sku !== undefined) existingProduct.sku = product.sku; if (product.regular_price !== undefined && product.regular_price !== null) { existingProduct.regular_price = product.regular_price; } if (product.sale_price !== undefined && product.sale_price !== null) { existingProduct.sale_price = product.sale_price; } if (product.on_sale !== undefined) { existingProduct.on_sale = product.on_sale; } if (product.tags) { existingProduct.tags = product.tags as any; } if (product.categories) { existingProduct.categories = product.categories as any; } await this.wpProductModel.save(existingProduct); } } async updateWpProductVaritation( siteId: number, productId: string, variationId: string, variation: UpdateVariationDTO ) { const existingVariation = await this.findVariation( siteId, productId, variationId ); if (existingVariation) { existingVariation.name = variation.name; existingVariation.sku = variation.sku; if (variation.regular_price !== undefined && variation.regular_price !== null) { existingVariation.regular_price = variation.regular_price; } if (variation.sale_price !== undefined && variation.sale_price !== null) { existingVariation.sale_price = variation.sale_price; } await this.variationModel.save(existingVariation); } } async syncProductAndVariations( siteId: number, product: WpProduct, variations: Variation[] ) { // 1. 处理产品同步 let existingProduct = await this.findProduct(siteId, String(product.id)); if (existingProduct) { existingProduct.name = product.name; existingProduct.status = product.status; existingProduct.type = product.type; existingProduct.sku = product.sku; if (product.regular_price !== undefined && product.regular_price !== null && String(product.regular_price) !== '') { existingProduct.regular_price = Number(product.regular_price); } if (product.sale_price !== undefined && product.sale_price !== null && String(product.sale_price) !== '') { existingProduct.sale_price = Number(product.sale_price); } existingProduct.on_sale = product.on_sale; existingProduct.metadata = product.metadata; existingProduct.tags = product.tags; existingProduct.categories = product.categories; await this.wpProductModel.save(existingProduct); } else { existingProduct = this.wpProductModel.create({ siteId, externalProductId: String(product.id), sku: product.sku, status: product.status, name: product.name, type: product.type, ...(product.regular_price ? { regular_price: Number(product.regular_price) } : {}), ...(product.sale_price ? { sale_price: Number(product.sale_price) } : {}), on_sale: product.on_sale, metadata: product.metadata, tags: product.tags, categories: product.categories, }); await this.wpProductModel.save(existingProduct); } await this.ensureSiteSku(product.sku, siteId, product.type); // 2. 处理变体同步 if (product.type === 'variable') { const currentVariations = await this.variationModel.find({ where: { siteId, externalProductId: String(product.id), on_delete: false }, }); const syncedVariationIds = new Set(variations.map(v => String(v.id))); const variationsToDelete = currentVariations.filter( dbVariation => !syncedVariationIds.has(String(dbVariation.externalVariationId)) ); if (variationsToDelete.length > 0) { const idsToDelete = variationsToDelete.map(v => v.id); await this.variationModel.delete(idsToDelete); } for (const variation of variations) { await this.ensureSiteSku(variation.sku, siteId); const existingVariation = await this.findVariation( siteId, String(product.id), String(variation.id) ); if (existingVariation) { existingVariation.name = variation.name; existingVariation.attributes = variation.attributes; variation.regular_price && (existingVariation.regular_price = variation.regular_price); variation.sale_price && (existingVariation.sale_price = variation.sale_price); existingVariation.on_sale = variation.on_sale; await this.variationModel.save(existingVariation); } else { const newVariation = this.variationModel.create({ siteId, externalProductId: String(product.id), externalVariationId: String(variation.id), productId: existingProduct.id, sku: variation.sku, name: variation.name, ...(variation.regular_price ? { regular_price: variation.regular_price } : {}), ...(variation.sale_price ? { sale_price: variation.sale_price } : {}), on_sale: variation.on_sale, attributes: variation.attributes, }); await this.variationModel.save(newVariation); } } } else { // 清理之前的变体 await this.variationModel.update( { siteId, externalProductId: String(product.id) }, { on_delete: true } ); } } async syncVariation(siteId: number, productId: string, variation: Variation) { await this.ensureSiteSku(variation.sku, siteId); let existingProduct = await this.findProduct(siteId, String(productId)); if (!existingProduct) return; const existingVariation = await this.variationModel.findOne({ where: { siteId, externalProductId: String(productId), externalVariationId: String(variation.id), }, }); if (existingVariation) { existingVariation.name = variation.name; existingVariation.attributes = variation.attributes; variation.regular_price && (existingVariation.regular_price = variation.regular_price); variation.sale_price && (existingVariation.sale_price = variation.sale_price); existingVariation.on_sale = variation.on_sale; await this.variationModel.save(existingVariation); } else { const newVariation = this.variationModel.create({ siteId, externalProductId: String(productId), externalVariationId: String(variation.id), productId: existingProduct.id, sku: variation.sku, name: variation.name, ...(variation.regular_price ? { regular_price: variation.regular_price } : {}), ...(variation.sale_price ? { sale_price: variation.sale_price } : {}), on_sale: variation.on_sale, attributes: variation.attributes, }); await this.variationModel.save(newVariation); } } async getProductList(param: QueryWpProductDTO) { const { current = 1, pageSize = 10, name, siteId, status, skus } = param; // 第一步:先查询分页的产品 const where: any = {}; if (siteId) { where.siteId = siteId; } const nameFilter = name ? name.split(' ').filter(Boolean) : []; if (nameFilter.length > 0) { const nameConditions = nameFilter.map(word => Like(`%${word}%`)); where.name = And(...nameConditions); } if (status) { where.status = status; } if (skus && skus.length > 0) { // 查找 WpProduct 中匹配的 SKU const wpProducts = await this.wpProductModel.find({ select: ['id'], where: { sku: In(skus), on_delete: false }, }); let ids = wpProducts.map(p => p.id); // 查找 Variation 中匹配的 SKU,并获取对应的 WpProduct const variations = await this.variationModel.find({ select: ['siteId', 'externalProductId'], where: { sku: In(skus), on_delete: false }, }); if (variations.length > 0) { const variationParentConditions = variations.map(v => ({ siteId: v.siteId, externalProductId: v.externalProductId, on_delete: false })); // 这里不能直接用 In,因为是 siteId 和 externalProductId 的组合键 // 可以用 OR 条件查询对应的 WpProduct ID // 或者,更简单的是,如果我们能获取到 ids... // 既然 variationParentConditions 可能是多个,我们可以分批查或者构造查询 // 使用 QueryBuilder 查 ID if (variationParentConditions.length > 0) { const qb = this.wpProductModel.createQueryBuilder('wp_product') .select('wp_product.id'); qb.where('1=0'); // Start with false variationParentConditions.forEach((cond, index) => { qb.orWhere(`(wp_product.siteId = :siteId${index} AND wp_product.externalProductId = :epid${index} AND wp_product.on_delete = :del${index})`, { [`siteId${index}`]: cond.siteId, [`epid${index}`]: cond.externalProductId, [`del${index}`]: false }); }); const parentProducts = await qb.getMany(); ids = [...ids, ...parentProducts.map(p => p.id)]; } } if (ids.length === 0) { return { items: [], total: 0, current, pageSize, }; } where.id = In([...new Set(ids)]); } where.on_delete = false; const products = await this.wpProductModel.find({ relations: ['site'], where, skip: (current - 1) * pageSize, take: pageSize, }); const total = await this.wpProductModel.count({ where, }); if (products.length === 0) { return { items: [], total, current, pageSize, }; } const variationQuery = this.wpProductModel .createQueryBuilder('wp_product') .leftJoin(Variation, 'variation', 'variation.productId = wp_product.id') .leftJoin( Product, 'product', 'wp_product.sku = product.sku' ) .leftJoin( Product, 'variation_product', 'variation.sku = variation_product.sku' ) .select([ 'wp_product.*', 'variation.id as variation_id', 'variation.siteId as variation_siteId', 'variation.externalProductId as variation_externalProductId', 'variation.externalVariationId as variation_externalVariationId', 'variation.productId as variation_productId', 'variation.sku as variation_sku', 'variation.name as variation_name', 'variation.regular_price as variation_regular_price', 'variation.sale_price as variation_sale_price', 'variation.on_sale as variation_on_sale', 'product.name as product_name', // 关联查询返回 product.name 'variation_product.name as variation_product_name', // 关联查询返回 variation 的产品 name ]) .where('wp_product.id IN (:...ids) AND wp_product.on_delete = false ', { ids: products.map(product => product.id), }); const rawResult = await variationQuery.getRawMany(); // 数据转换 const items = rawResult.reduce((acc, row) => { // 在累加器中查找当前产品 let product = acc.find(p => p.id === row.id); // 如果产品不存在,则创建新产品 if (!product) { // 从原始产品列表中查找,以获取 'site' 关联数据 const originalProduct = products.find(p => p.id === row.id); product = { ...Object.keys(row) .filter(key => !key.startsWith('variation_')) .reduce((obj, key) => { obj[key] = row[key]; return obj; }, {}), variations: [], // 附加 'site' 对象 site: originalProduct.site, }; acc.push(product); } if (row.variation_id) { const variation: any = Object.keys(row) .filter(key => key.startsWith('variation_')) .reduce((obj, key) => { obj[key.replace('variation_', '')] = row[key]; return obj; }, {}); product.variations.push(variation); } return acc; }, []); return { items, total, current, pageSize, }; } /** * 检查 SKU 是否重复 * @param sku SKU 编码 * @param excludeSiteId 需要排除的站点 ID * @param excludeProductId 需要排除的产品 ID * @param excludeVariationId 需要排除的变体 ID * @returns 是否重复 */ async isSkuDuplicate( sku: string, excludeSiteId?: number, excludeProductId?: string, excludeVariationId?: string ): Promise { if (!sku) return false; const where: any = { sku }; const varWhere: any = { sku }; if (excludeVariationId) { varWhere.siteId = Not(excludeSiteId); varWhere.externalProductId = Not(excludeProductId); varWhere.externalVariationId = Not(excludeVariationId); } else if (excludeProductId) { where.siteId = Not(excludeSiteId); where.externalProductId = Not(excludeProductId); } const productDuplicate = await this.wpProductModel.findOne({ where, }); if (productDuplicate) { return true; } const variationDuplicate = await this.variationModel.findOne({ where: varWhere, }); return !!variationDuplicate; } async deleteById(id: number) { const product = await this.wpProductModel.findOne({ where: { id } }); if (!product) throw new Error('产品不存在'); await this.delWpProduct(product.siteId, product.externalProductId); return true; } async delWpProduct(siteId: number, productId: string) { const product = await this.wpProductModel.findOne({ where: { siteId, externalProductId: productId }, }); if (!product) throw new Error('未找到该商品'); await this.variationModel.createQueryBuilder('variation') .update() .set({ on_delete: true }) .where('variation.siteId = :siteId AND variation.externalProductId = :externalProductId', { siteId, externalProductId: productId }) .execute(); const sums = await this.wpProductModel.createQueryBuilder('wp_product') .update() .set({ on_delete: true }) .where('wp_product.siteId = :siteId AND wp_product.externalProductId = :externalProductId', { siteId, externalProductId: productId }) .execute(); console.log(sums); //await this.variationModel.delete({ siteId, externalProductId: productId }); //await this.wpProductModel.delete({ siteId, externalProductId: productId }); } async findProductsByName(name: string): Promise { const nameFilter = name ? name.split(' ').filter(Boolean) : []; const query = this.wpProductModel.createQueryBuilder('product'); // 保证 sku 不为空 query.where('product.sku IS NOT NULL AND product.on_delete = false'); if (nameFilter.length > 0 || name) { const params: Record = {}; const conditions: string[] = []; // 英文名关键词全部匹配(AND) if (nameFilter.length > 0) { const nameConds = nameFilter.map((word, index) => { const key = `name${index}`; params[key] = `%${word}%`; return `product.name LIKE :${key}`; }); conditions.push(`(${nameConds.join(' AND ')})`); } // 中文名模糊匹配 if (name) { params['nameCn'] = `%${name}%`; conditions.push(`product.nameCn LIKE :nameCn`); } // 英文名关键词匹配 OR 中文名匹配 query.andWhere(`(${conditions.join(' OR ')})`, params); } query.take(50); return await query.getMany(); } async syncToProduct(wpProductId: number) { const wpProduct = await this.wpProductModel.findOne({ where: { id: wpProductId }, relations: ['site'] }); if (!wpProduct) throw new Error('WpProduct not found'); const sku = wpProduct.sku; if (!sku) throw new Error('WpProduct has no SKU'); // Try to find by main SKU let product = await this.productModel.findOne({ where: { sku } }); // If not found, try to remove prefix if site has one if (!product && wpProduct.site && wpProduct.site.skuPrefix && sku.startsWith(wpProduct.site.skuPrefix)) { const skuWithoutPrefix = sku.slice(wpProduct.site.skuPrefix.length); product = await this.productModel.findOne({ where: { sku: skuWithoutPrefix } }); } // If still not found, try siteSkus if (!product) { const siteSku = await this.productSiteSkuModel.findOne({ where: { siteSku: sku }, relations: ['product'] }); if (siteSku) { product = siteSku.product; } } if (!product) { throw new Error('Local Product not found for SKU: ' + sku); } // Update fields if (wpProduct.regular_price) product.price = Number(wpProduct.regular_price); if (wpProduct.sale_price) product.promotionPrice = Number(wpProduct.sale_price); await this.productModel.save(product); return true; } /** * 确保 SKU 存在于 ProductSiteSku 中,并根据 WpProduct 类型更新 Product 类型 * @param sku * @param siteId 站点ID,用于去除前缀 * @param wpType WpProduct 类型 */ private async ensureSiteSku(sku: string, siteId?: number, wpType?: string) { if (!sku) return; // 查找本地产品 let product = await this.productModel.findOne({ where: { sku } }); if (!product && siteId) { // 如果找不到且有 siteId,尝试去除前缀再查找 const site = await this.siteService.get(siteId, true); if (site && site.skuPrefix && sku.startsWith(site.skuPrefix)) { const skuWithoutPrefix = sku.slice(site.skuPrefix.length); product = await this.productModel.findOne({ where: { sku: skuWithoutPrefix } }); } } if (product) { // 更新产品类型 if (wpType) { // simple 对应 single, 其他对应 bundle const targetType = wpType === 'simple' ? 'single' : 'bundle'; if (product.type !== targetType) { product.type = targetType; await this.productModel.save(product); } } // 检查是否已存在 ProductSiteSku const existingSiteSku = await this.productSiteSkuModel.findOne({ where: { productId: product.id, siteSku: sku }, }); if (!existingSiteSku) { await this.productSiteSkuModel.save({ productId: product.id, siteSku: sku, }); } } } }