1215 lines
42 KiB
TypeScript
1215 lines
42 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|