zksu
/
API
forked from yoone/API
1
0
Fork 0
API/src/service/wp_product.service.ts

1215 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<WpProduct>;
@InjectEntityModel(Variation)
variationModel: Repository<Variation>;
@InjectEntityModel(Product)
productModel: Repository<Product>;
@InjectEntityModel(ProductSiteSku)
productSiteSkuModel: Repository<ProductSiteSku>;
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<string, Product>();
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<number, WpProduct[]>();
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<number, WpProduct[]>();
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<WpProduct | null> {
return await this.wpProductModel.findOne({
where: { siteId, externalProductId },
});
}
async findVariation(
siteId: number,
externalProductId: string,
externalVariationId: string
): Promise<Variation | null> {
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<boolean> {
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<WpProduct[]> {
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<string, string> = {};
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,
});
}
}
}
}