From 2e62a0cdb2e801e81afedd406e91dbba75917f26 Mon Sep 17 00:00:00 2001 From: tikkhun Date: Thu, 8 Jan 2026 15:03:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?SKU=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加字典排序字段支持 优化产品同步流程,支持通过SKU同步 重构SKU模板生成逻辑,支持分类属性排序 完善产品导入导出功能,增加分类字段处理 统一产品操作方法,提升代码可维护性 --- package-lock.json | 17 -- src/adapter/shopyy.adapter.ts | 2 +- src/adapter/woocommerce.adapter.ts | 356 ++++++++++++++++++--------- src/controller/product.controller.ts | 43 ++-- src/db/seeds/template.seeder.ts | 39 ++- src/dto/site-api.dto.ts | 3 + src/dto/woocommerce.dto.ts | 4 +- src/entity/dict.entity.ts | 4 + src/service/product.service.ts | 118 ++++----- src/service/site-api.service.ts | 43 ++-- src/service/wp.service.ts | 2 +- 11 files changed, 379 insertions(+), 252 deletions(-) diff --git a/package-lock.json b/package-lock.json index e207cd1..405c79b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -523,23 +523,6 @@ "node": ">=18" } }, - "node_modules/@faker-js/faker": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", - "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" - } - }, "node_modules/@hapi/bourne": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz", diff --git a/src/adapter/shopyy.adapter.ts b/src/adapter/shopyy.adapter.ts index 6528355..f0abdcb 100644 --- a/src/adapter/shopyy.adapter.ts +++ b/src/adapter/shopyy.adapter.ts @@ -774,7 +774,7 @@ export class ShopyyAdapter implements ISiteAdapter { return data } - mapCreateProductParams(data: Partial): any { + mapCreateProductParams(data: Partial): Partial { // 构建 ShopYY 产品创建参数 const params: any = { name: data.name || '', diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts index 3f72e75..8a61b80 100644 --- a/src/adapter/woocommerce.adapter.ts +++ b/src/adapter/woocommerce.adapter.ts @@ -150,9 +150,30 @@ export class WooCommerceAdapter implements ISiteAdapter { } // 客户操作方法 - async getCustomer(where: {id: string | number}): Promise { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); - const res = await api.get(`customers/${where.id}`); + async getCustomer(where: Partial>): Promise { + const api = this.wpService.createApi(this.site, 'wc/v3'); + // 根据提供的条件构建查询参数 + let endpoint: string; + if (where.id) { + endpoint = `customers/${where.id}`; + } else if (where.email) { + // 使用邮箱查询客户 + const res = await api.get('customers', { params: { email: where.email } }); + if (!res.data || res.data.length === 0) { + throw new Error('Customer not found'); + } + return this.mapPlatformToUnifiedCustomer(res.data[0]); + } else if (where.phone) { + // 使用电话查询客户 + const res = await api.get('customers', { params: { search: where.phone } }); + if (!res.data || res.data.length === 0) { + throw new Error('Customer not found'); + } + return this.mapPlatformToUnifiedCustomer(res.data[0]); + } else { + throw new Error('Must provide at least one of id, email, or phone'); + } + const res = await api.get(endpoint); return this.mapPlatformToUnifiedCustomer(res.data); } @@ -175,7 +196,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllCustomers(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有客户数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); // 处理orderBy参数,转换为WooCommerce API需要的格式 const requestParams = this.mapCustomerSearchParams(params || {}); @@ -185,20 +206,42 @@ export class WooCommerceAdapter implements ISiteAdapter { } async createCustomer(data: Partial): Promise { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.post('customers', data); return this.mapPlatformToUnifiedCustomer(res.data); } - async updateCustomer(where: {id: string | number}, data: Partial): Promise { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); - const res = await api.put(`customers/${where.id}`, data); + async updateCustomer(where: Partial>, data: Partial): Promise { + const api = this.wpService.createApi(this.site, 'wc/v3'); + let customerId: string | number; + + // 先根据条件获取客户ID + if (where.id) { + customerId = where.id; + } else { + // 如果没有提供ID,则先查询客户 + const customer = await this.getCustomer(where); + customerId = customer.id; + } + + const res = await api.put(`customers/${customerId}`, data); return this.mapPlatformToUnifiedCustomer(res.data); } - async deleteCustomer(where: {id: string | number}): Promise { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); - await api.delete(`customers/${where.id}`, { force: true }); + async deleteCustomer(where: Partial>): Promise { + const api = this.wpService.createApi(this.site, 'wc/v3'); + let customerId: string | number; + + // 先根据条件获取客户ID + if (where.id) { + customerId = where.id; + } else { + // 如果没有提供ID,则先查询客户 + const customer = await this.getCustomer(where); + customerId = customer.id; + } + + await api.delete(`customers/${customerId}`, { force: true }); return true; } @@ -242,7 +285,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllMedia(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有媒体数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const media = await this.wpService.sdkGetAll(api, 'media', params); return media.map((mediaItem: any) => this.mapPlatformToUnifiedMedia(mediaItem)); } @@ -420,7 +463,7 @@ export class WooCommerceAdapter implements ISiteAdapter { // 订单操作方法 async getOrder(where: {id: string | number}): Promise { // 获取单个订单详情 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.get(`orders/${where.id}`); return this.mapPlatformToUnifiedOrder(res.data); } @@ -462,7 +505,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllOrders(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有订单数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const orders = await this.wpService.sdkGetAll(api, 'orders', params); return orders.map((order: any) => this.mapPlatformToUnifiedOrder(order)); } @@ -481,7 +524,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async createOrder(data: Partial): Promise { // 创建订单并返回统一订单DTO - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.post('orders', data); return this.mapPlatformToUnifiedOrder(res.data); } @@ -493,7 +536,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async deleteOrder(where: {id: string | number}): Promise { // 删除订单 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); await api.delete(`orders/${where.id}`, { force: true }); return true; } @@ -558,14 +601,14 @@ export class WooCommerceAdapter implements ISiteAdapter { async getOrderNotes(orderId: string | number): Promise { // 获取订单备注列表 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.get(`orders/${orderId}/notes`); return res.data; } async createOrderNote(orderId: string | number, data: any): Promise { // 创建订单备注 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.post(`orders/${orderId}/notes`, data); return res.data; } @@ -576,7 +619,7 @@ export class WooCommerceAdapter implements ISiteAdapter { }): Promise { throw new Error('暂未实现'); // 取消订单履行 - // const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + // const api = this.wpService.createApi(this.site, 'wc/v3'); // try { // // 将订单状态改回处理中 @@ -599,15 +642,105 @@ export class WooCommerceAdapter implements ISiteAdapter { } // ========== 产品映射方法 ========== - mapUnifiedToPlatformProduct(data: Partial) { - return data; + mapUnifiedToPlatformProduct(data: Partial): Partial { + // 将统一产品DTO映射为WooCommerce产品数据 + // 基本字段映射 + const mapped: Partial = { + id: data.id as number, + name: data.name, + type: data.type, + status: data.status, + sku: data.sku, + regular_price: data.regular_price, + sale_price: data.sale_price, + price: data.price, + stock_status: data.stock_status as 'instock' | 'outofstock' | 'onbackorder', + stock_quantity: data.stock_quantity, + // 映射更多WooCommerce产品特有的字段 + // featured: data.featured, + // catalog_visibility: data.catalog_visibility, + // date_on_sale_from: data.date_on_sale_from, + // date_on_sale_to: data.date_on_sale_to, + // virtual: data.virtual, + // downloadable: data.downloadable, + // description: data.description, + // short_description: data.short_description, + // slug: data.slug, + // manage_stock: data.manage_stock, + // backorders: data.backorders as 'no' | 'notify' | 'yes', + // sold_individually: data.sold_individually, + // weight: data.weight, + // dimensions: data.dimensions, + // shipping_class: data.shipping_class, + // tax_class: data.tax_class, + }; + + // 映射图片数据 + if (data.images && Array.isArray(data.images)) { + mapped.images = data.images.map(img => ({ + id: img.id as number, + src: img.src, + name: img.name, + alt: img.alt, + })); + } + + // 映射分类数据 + if (data.categories && Array.isArray(data.categories)) { + mapped.categories = data.categories.map(cat => ({ + // id: cat.id as number, //TODO + name: cat.name, + })); + } + + // 映射标签数据 + // TODO tags 应该可以设置 + // if (data.tags && Array.isArray(data.tags)) { + // mapped.tags = data.tags.map(tag => { + // return ({ + // // id: tag.id as number, + // name: tag.name, + // }); + // }); + // } + + // 映射属性数据 + if (data.attributes && Array.isArray(data.attributes)) { + mapped.attributes = data.attributes.map(attr => ({ + // id 由于我们这个主要用来存,所以不映射 id + name: attr.name, + visible: attr.visible, + variation: attr.variation, + options: attr.options + })); + } + + // 映射变体数据(注意:WooCommerce API 中变体通常通过单独的端点处理) + // 这里只映射变体的基本信息,具体创建/更新变体需要额外处理 + if (data.variations && Array.isArray(data.variations)) { + // 对于WooProduct类型,variations字段只存储变体ID + mapped.variations = data.variations.map(variation => variation.id as number); + } + + // 映射下载数据(如果产品是可下载的) + // if (data.downloads && Array.isArray(data.downloads)) { + // mapped.downloads = data.downloads.map(download => ({ + // id: download.id as number, + // name: download.name, + // file: download.file, + // })); + // } + + return mapped; } - mapCreateProductParams(data: Partial) { - return data; + mapCreateProductParams(data: Partial):Partial { + const {id,...mapped}= this.mapUnifiedToPlatformProduct(data); + // 创建不带 id + return mapped } - mapUpdateProductParams(data: Partial) { - return data; + mapUpdateProductParams(data: Partial): Partial { + return this.mapUnifiedToPlatformProduct(data); } mapProductSearchParams(params: UnifiedSearchParamsDTO): Partial { @@ -700,14 +833,14 @@ export class WooCommerceAdapter implements ISiteAdapter { return mapped; } - mapPlatformToUnifiedProduct(item: WooProduct): UnifiedProductDTO { + mapPlatformToUnifiedProduct(data: WooProduct): UnifiedProductDTO { // 将 WooCommerce 产品数据映射为统一产品DTO // 保留常用字段与时间信息以便前端统一展示 // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#product-properties // 映射变体数据 - const mappedVariations = item.variations && Array.isArray(item.variations) - ? item.variations + const mappedVariations = data.variations && Array.isArray(data.variations) + ? data.variations .filter((variation: any) => typeof variation !== 'number') // 过滤掉数字类型的变体ID .map((variation: any) => { // 将变体属性转换为统一格式 @@ -734,7 +867,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return { id: variation.id, - name: variation.name || item.name, // 如果变体没有名称,使用父产品名称 + name: variation.name || data.name, // 如果变体没有名称,使用父产品名称 sku: variation.sku || '', regular_price: String(variation.regular_price || ''), sale_price: String(variation.sale_price || ''), @@ -748,34 +881,34 @@ export class WooCommerceAdapter implements ISiteAdapter { : []; return { - id: item.id, - date_created: item.date_created, - date_modified: item.date_modified, - type: item.type, // simple grouped external variable - status: item.status, // draft pending private publish - sku: item.sku, - name: item.name, + id: data.id, + date_created: data.date_created, + date_modified: data.date_modified, + type: data.type, // simple grouped external variable + status: data.status, // draft pending private publish + sku: data.sku, + name: data.name, //价格 - regular_price: item.regular_price, - sale_price: item.sale_price, - price: item.price, - stock_status: item.stock_status, - stock_quantity: item.stock_quantity, - images: (item.images || []).map((img: any) => ({ + regular_price: data.regular_price, + sale_price: data.sale_price, + price: data.price, + stock_status: data.stock_status, + stock_quantity: data.stock_quantity, + images: (data.images || []).map((img: any) => ({ id: img.id, src: img.src, name: img.name, alt: img.alt, })), - categories: (item.categories || []).map((c: any) => ({ + categories: (data.categories || []).map((c: any) => ({ id: c.id, name: c.name, })), - tags: (item.tags || []).map((t: any) => ({ + tags: (data.tags || []).map((t: any) => ({ id: t.id, name: t.name, })), - attributes: (item.attributes || []).map(attr => ({ + attributes: (data.attributes || []).map(attr => ({ id: attr.id, name: attr.name || '', position: attr.position, @@ -784,25 +917,39 @@ export class WooCommerceAdapter implements ISiteAdapter { options: attr.options || [] })), variations: mappedVariations, - permalink: item.permalink, - raw: item, + permalink: data.permalink, + raw: data, }; } - async getProduct({id, sku}: {id?: string, sku?:string}){ + // 判断是否是这个站点的sku + isSiteSkuThisSite(sku: string,){ + return sku.startsWith(this.site.skuPrefix+'-'); + } + async getProduct(where: Partial>): Promise{ + const { id, sku } = where; if(id) return this.getProductById(id); - if(sku) return this.getProductBySku(sku) - return this.getProductById(id || sku || ''); + if(sku) return this.getProductBySku(sku); + throw new Error('必须提供id或sku参数'); } async getProductBySku(sku: string){ - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); - const res = await api.get(`products?sku=${sku}`); - const product = res.data[0]; + // const api = this.wpService.createApi(this.site, 'wc/v3'); + // const res = await api.get(`products`,{ + // sku + // }); + // const product = res.data[0]; + const res = await this.wpService.getProducts(this.site,{ + sku, + page:1, + per_page:1, + }); + const product = res?.items?.[0]; + if(!product) return null return this.mapPlatformToUnifiedProduct(product); } // 产品操作方法 async getProductById(id: string | number): Promise { // 获取单个产品详情并映射为统一产品DTO - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const res = await api.get(`products/${id}`); const product = res.data; @@ -849,7 +996,7 @@ export class WooCommerceAdapter implements ISiteAdapter { try { // 批量获取该产品的所有变体数据 const variations = await this.wpService.sdkGetAll( - (this.wpService as any).createApi(this.site, 'wc/v3'), + this.wpService.createApi(this.site, 'wc/v3'), `products/${item.id}/variations` ); // 将完整的变体数据添加到产品对象中 @@ -876,7 +1023,7 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllProducts(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有产品数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const products = await this.wpService.sdkGetAll(api, 'products', params); // 对于类型为 variable 的产品,需要加载完整的变体数据 @@ -906,55 +1053,31 @@ export class WooCommerceAdapter implements ISiteAdapter { async createProduct(data: Partial): Promise { // 创建产品并返回统一产品DTO - const res = await this.wpService.createProduct(this.site, data); - return this.mapPlatformToUnifiedProduct(res); + const createData = this.mapCreateProductParams(data); + const res = await this.wpService.createProduct(this.site, createData); return this.mapPlatformToUnifiedProduct(res); } - async updateProduct(where: {id?: string | number, sku?: string}, data: Partial): Promise { - async updateProduct(where: {id?: string | number, sku?: string}, data: Partial): Promise { + async updateProduct(where: Partial>, data: Partial): Promise { // 更新产品并返回统一产品DTO - let productId: string; - if (where.id) { - productId = String(where.id); - } else if (where.sku) { - // 通过sku获取产品ID - const product = await this.getProductBySku(where.sku); - productId = String(product.id); - } else { - throw new Error('必须提供id或sku参数'); + const product = await this.getProduct(where); + if(!product){ + throw new Error('产品不存在'); } - const res = await this.wpService.updateProduct(this.site, productId, data as any); + const updateData = this.mapUpdateProductParams(data); + const res = await this.wpService.updateProduct(this.site, String(product.id), updateData as any); return res; } - async deleteProduct(where: {id?: string | number, sku?: string}): Promise { - async deleteProduct(where: {id?: string | number, sku?: string}): Promise { + async deleteProduct(where: Partial>): Promise { // 删除产品 - let productId: string; - if (where.id) { - productId = String(where.id); - } else if (where.sku) { - // 通过sku获取产品ID - const product = await this.getProductBySku(where.sku); - productId = String(product.id); - } else { - throw new Error('必须提供id或sku参数'); + const product = await this.getProduct(where); + if(!product){ + throw new Error('产品不存在'); } - let productId: string; - if (where.id) { - productId = String(where.id); - } else if (where.sku) { - // 通过sku获取产品ID - const product = await this.getProductBySku(where.sku); - productId = String(product.id); - } else { - throw new Error('必须提供id或sku参数'); - } - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); try { - await api.delete(`products/${productId}`, { force: true }); - await api.delete(`products/${productId}`, { force: true }); + await api.delete(`products/${product.id}`, { force: true }); return true; } catch (e) { return false; @@ -974,7 +1097,7 @@ export class WooCommerceAdapter implements ISiteAdapter { return data; } - mapPlatformToUnifiedReview(item: any): UnifiedReviewDTO & { raw: any } { + mapPlatformToUnifiedReview(item: any): UnifiedReviewDTO { // 将 WooCommerce 评论数据映射为统一评论DTO return { id: item.id, @@ -985,7 +1108,7 @@ export class WooCommerceAdapter implements ISiteAdapter { rating: item.rating, status: item.status, date_created: item.date_created, - raw: item + date_modified: item.date_modified, }; } @@ -1017,28 +1140,33 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllReviews(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有评论数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const reviews = await this.wpService.sdkGetAll(api, 'products/reviews', params); return reviews.map((review: any) => this.mapPlatformToUnifiedReview(review)); return reviews.map((review: any) => this.mapPlatformToUnifiedReview(review)); } - async createReview(data: any): Promise { + async createReview(data: CreateReviewDTO): Promise { const res = await this.wpService.createReview(this.site, data); return this.mapPlatformToUnifiedReview(res); return this.mapPlatformToUnifiedReview(res); } - async updateReview(where: {id: number}, data: any): Promise { - const res = await this.wpService.updateReview(this.site, where.id, data); - return this.mapPlatformToUnifiedReview(res); - async updateReview(where: {id: number}, data: any): Promise { - const res = await this.wpService.updateReview(this.site, where.id, data); + async updateReview(where: Partial>, data: UpdateReviewDTO): Promise { + const { id } = where; + if (!id) { + throw new Error('必须提供评论ID'); + } + const res = await this.wpService.updateReview(this.site, Number(id), data); return this.mapPlatformToUnifiedReview(res); } - async deleteReview(where: {id: number}): Promise { - return await this.wpService.deleteReview(this.site, where.id); + async deleteReview(where: Partial>): Promise { + const { id } = where; + if (!id) { + throw new Error('必须提供评论ID'); + } + return await this.wpService.deleteReview(this.site, Number(id)); } // ========== 订阅映射方法 ========== @@ -1098,22 +1226,22 @@ export class WooCommerceAdapter implements ISiteAdapter { async getAllSubscriptions(params?: UnifiedSearchParamsDTO): Promise { // 使用sdkGetAll获取所有订阅数据,不受分页限制 - async getAllSubscriptions(params?: UnifiedSearchParamsDTO): Promise { - // 使用sdkGetAll获取所有订阅数据,不受分页限制 - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const subscriptions = await this.wpService.sdkGetAll(api, 'subscriptions', params); return subscriptions.map((subscription: any) => this.mapPlatformToUnifiedSubscription(subscription)); } // ========== 变体映射方法 ========== mapPlatformToUnifiedVariation(data: any): UnifiedProductVariationDTO { - return data; + // 使用mapVariation方法来实现统一的变体映射逻辑 + return this.mapVariation(data); } mapUnifiedToPlatformVariation(data: Partial) { return data; // ========== 变体映射方法 ========== mapPlatformToUnifiedVariation(data: any): UnifiedProductVariationDTO { - return data; + // 使用mapVariation方法来实现统一的变体映射逻辑 + return this.mapVariation(data); } mapUnifiedToPlatformVariation(data: Partial) { return data; @@ -1197,7 +1325,7 @@ export class WooCommerceAdapter implements ISiteAdapter { // 获取所有产品变体 async getAllVariations(productId: string | number, params?: UnifiedSearchParamsDTO): Promise { try { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const variations = await this.wpService.sdkGetAll(api, `products/${productId}/variations`, params); // 获取产品名称用于变体显示 @@ -1395,7 +1523,7 @@ export class WooCommerceAdapter implements ISiteAdapter { // 获取所有webhooks async getAllWebhooks(params?: UnifiedSearchParamsDTO): Promise { try { - const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const api = this.wpService.createApi(this.site, 'wc/v3'); const webhooks = await this.wpService.sdkGetAll(api, 'webhooks', params); return webhooks.map((webhook: any) => this.mapPlatformToUnifiedWebhook(webhook)); } catch (error) { @@ -1476,11 +1604,11 @@ export class WooCommerceAdapter implements ISiteAdapter { return links; } - batchProcessOrders?(data: { create?: any[]; update?: any[]; delete?: Array; }): Promise { + batchProcessOrders?(data: BatchOperationDTO): Promise { throw new Error('Method not implemented.'); } - batchProcessCustomers?(data: { create?: any[]; update?: any[]; delete?: Array; }): Promise { + batchProcessCustomers?(data: BatchOperationDTO): Promise { throw new Error('Method not implemented.'); } } diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index aef81f8..c263d16 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -698,10 +698,10 @@ export class ProductController { // 从站点同步产品到本地 @ApiOkResponse({ description: '从站点同步产品到本地', type: ProductRes }) @Post('/sync-from-site') - async syncProductFromSite(@Body() body: { siteId: number; siteProductId: string | number }) { + async syncProductFromSite(@Body() body: { siteId: number; siteProductId: string | number ,sku: string}) { try { - const { siteId, siteProductId } = body; - const product = await this.productService.syncProductFromSite(siteId, siteProductId); + const { siteId, siteProductId, sku } = body; + const product = await this.productService.syncProductFromSite(siteId, siteProductId, sku); return successResponse(product); } catch (error) { return errorResponse(error?.message || error); @@ -713,25 +713,26 @@ export class ProductController { @Post('/batch-sync-from-site') async batchSyncFromSite(@Body() body: { siteId: number; siteProductIds: (string | number)[] }) { try { - const { siteId, siteProductIds } = body; - const result = await this.productService.batchSyncFromSite(siteId, siteProductIds); - // 将服务层返回的结果转换为统一格式 - const errors = result.errors.map((error: string) => { - // 提取产品ID部分作为标识符 - const match = error.match(/站点产品ID (\d+) /); - const identifier = match ? match[1] : 'unknown'; - return { - identifier: identifier, - error: error - }; - }); + throw new Error('批量同步产品到本地暂未实现'); + // const { siteId, siteProductIds } = body; + // const result = await this.productService.batchSyncFromSite(siteId, siteProductIds.map((id) => ({ siteProductId: id, sku: '' }))); + // // 将服务层返回的结果转换为统一格式 + // const errors = result.errors.map((error: string) => { + // // 提取产品ID部分作为标识符 + // const match = error.match(/站点产品ID (\d+) /); + // const identifier = match ? match[1] : 'unknown'; + // return { + // identifier: identifier, + // error: error + // }; + // }); - return successResponse({ - total: siteProductIds.length, - processed: result.synced + errors.length, - synced: result.synced, - errors: errors - }); + // return successResponse({ + // total: siteProductIds.length, + // processed: result.synced + errors.length, + // synced: result.synced, + // errors: errors + // }); } catch (error) { return errorResponse(error?.message || error); } diff --git a/src/db/seeds/template.seeder.ts b/src/db/seeds/template.seeder.ts index f12a208..c531f73 100644 --- a/src/db/seeds/template.seeder.ts +++ b/src/db/seeds/template.seeder.ts @@ -23,19 +23,38 @@ export default class TemplateSeeder implements Seeder { const templates = [ { name: 'product.sku', - value: "<%= [it.category.shortName].concat(it.attributes.map(a => a.shortName)).join('-') %>", + value: `<% + // 按分类判断属性排序逻辑 + if (it.category.name === 'nicotine-pouches') { + // 1. 定义 nicotine-pouches 专属的属性固定顺序 + const fixedOrder = ['brand','category', 'flavor', 'strength', 'humidity']; + sortedAttrShortNames = fixedOrder.map(attrKey => { + if(attrKey === 'category') return it.category.shortName + // 排序 + const matchedAttr = it.attributes.find(a => a?.dict?.name === attrKey); + return matchedAttr ? matchedAttr.shortName : ''; + }).filter(Boolean); // 移除空值,避免多余的 "-" + } else { + // 非目标分类,保留 attributes 原有顺序 + sortedAttrShortNames = it.attributes.map(a => a.shortName); + } + + // 4. 拼接分类名 + 排序后的属性名 + %><%= sortedAttrShortNames.join('-') %><% +%>`, description: '产品SKU模板', testData: JSON.stringify({ - category: { - shortName: 'CAT', + "category": { + "name": "nicotine-pouches", + "shortName": "NP" }, - attributes: [ - { shortName: 'BR' }, - { shortName: 'FL' }, - { shortName: '10MG' }, - { shortName: 'DRY' }, - ], - }), + "attributes": [ + { "dict": {"name": "brand"},"shortName": "YOONE" }, + { "dict": {"name": "flavor"},"shortName": "FL" }, + { "dict": {"name": "strength"},"shortName": "10MG" }, + { "dict": {"name": "humidity"},"shortName": "DRY" } + ] +}), }, { name: 'product.title', diff --git a/src/dto/site-api.dto.ts b/src/dto/site-api.dto.ts index a93f125..247c5c9 100644 --- a/src/dto/site-api.dto.ts +++ b/src/dto/site-api.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@midwayjs/swagger'; import { UnifiedPaginationDTO, } from './api.dto'; +import { Dict } from '../entity/dict.entity'; // export class UnifiedOrderWhere{ // [] // } @@ -137,6 +138,8 @@ export class UnifiedProductAttributeDTO { @ApiProperty({ description: '变体属性值(单个值)', required: false }) option?: string; + // 这个是属性的父级字典项 + dict?: Dict; } export class UnifiedProductVariationDTO { diff --git a/src/dto/woocommerce.dto.ts b/src/dto/woocommerce.dto.ts index 3150a78..f562d64 100644 --- a/src/dto/woocommerce.dto.ts +++ b/src/dto/woocommerce.dto.ts @@ -117,9 +117,9 @@ export interface WooProduct { // 购买备注 purchase_note?: string; // 分类列表 - categories?: Array<{ id: number; name?: string; slug?: string }>; + categories?: Array<{ id?: number; name?: string; slug?: string }>; // 标签列表 - tags?: Array<{ id: number; name?: string; slug?: string }>; + tags?: Array<{ id?: number; name?: string; slug?: string }>; // 菜单排序 menu_order?: number; // 元数据 diff --git a/src/entity/dict.entity.ts b/src/entity/dict.entity.ts index dc0d9af..15776da 100644 --- a/src/entity/dict.entity.ts +++ b/src/entity/dict.entity.ts @@ -29,6 +29,10 @@ export class Dict { @OneToMany(() => DictItem, item => item.dict) items: DictItem[]; + // 排序 + @Column({ default: 0, comment: '排序' }) + sort: number; + // 是否可删除 @Column({ default: true, comment: '是否可删除' }) deletable: boolean; diff --git a/src/service/product.service.ts b/src/service/product.service.ts index bbde5d9..384eca8 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -28,7 +28,7 @@ import { StockPoint } from '../entity/stock_point.entity'; import { StockService } from './stock.service'; import { TemplateService } from './template.service'; -import { SyncOperationResultDTO, UnifiedSearchParamsDTO } from '../dto/api.dto'; +import { BatchErrorItem, BatchOperationResult, SyncOperationResultDTO, UnifiedSearchParamsDTO } from '../dto/api.dto'; import { UnifiedProductDTO } from '../dto/site-api.dto'; import { ProductSiteSkuDTO, SyncProductToSiteDTO } from '../dto/site-sync.dto'; import { Category } from '../entity/category.entity'; @@ -225,7 +225,7 @@ export class ProductService { where: { sku, }, - relations: ['category', 'attributes', 'attributes.dict', 'siteSkus'] + relations: ['category', 'attributes', 'attributes.dict'] }); } @@ -1440,7 +1440,7 @@ export class ProductService { // 解析属性字段(分号分隔多值) const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []); - + // 将属性解析为 DTO 输入 const attributes: any[] = []; @@ -1455,6 +1455,9 @@ export class ProductService { } } + // 处理分类字段 + const category = val(rec.category); + return { sku, name: val(rec.name), @@ -1464,6 +1467,7 @@ export class ProductService { promotionPrice: num(rec.promotionPrice), type: val(rec.type), siteSkus: rec.siteSkus ? String(rec.siteSkus).split(',').map(s => s.trim()).filter(Boolean) : undefined, + category, // 添加分类字段 attributes: attributes.length > 0 ? attributes : undefined, } as any; @@ -1483,10 +1487,15 @@ export class ProductService { if (data.price !== undefined) dto.price = Number(data.price); if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice); - if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId); + // 处理分类字段 + if (data.categoryId !== undefined) { + dto.categoryId = Number(data.categoryId); + } else if (data.category) { + // 如果是字符串,需要后续在createProduct中处理 + dto.attributes = [...(dto.attributes || []), { dictName: 'category', title: data.category }]; + } // 默认值和特殊处理 - dto.attributes = Array.isArray(data.attributes) ? data.attributes : []; // 如果有组件信息,透传 @@ -1508,7 +1517,13 @@ export class ProductService { if (data.price !== undefined) dto.price = Number(data.price); if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice); - if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId); + // 处理分类字段 + if (data.categoryId !== undefined) { + dto.categoryId = Number(data.categoryId); + } else if (data.category) { + // 如果是字符串,需要后续在updateProduct中处理 + dto.attributes = [...(dto.attributes || []), { dictName: 'category', title: data.category }]; + } if (data.type !== undefined) dto.type = data.type; if (data.attributes !== undefined) dto.attributes = data.attributes; @@ -1548,8 +1563,8 @@ export class ProductService { esc(p.price), esc(p.promotionPrice), esc(p.type), - esc(p.description), + esc(p.category ? p.category.name || p.category.title : ''), // 添加分类字段 ]; // 属性数据 @@ -1575,9 +1590,9 @@ export class ProductService { // 导出所有产品为 CSV 文本 async exportProductsCSV(): Promise { - // 查询所有产品及其属性(包含字典关系)和组成 + // 查询所有产品及其属性(包含字典关系)、组成和分类 const products = await this.productModel.find({ - relations: ['attributes', 'attributes.dict', 'components'], + relations: ['attributes', 'attributes.dict', 'components', 'category'], order: { id: 'ASC' }, }); @@ -1612,8 +1627,8 @@ export class ProductService { 'price', 'promotionPrice', 'type', - 'description', + 'category', ]; // 动态属性表头 @@ -1640,7 +1655,7 @@ export class ProductService { } // 从 CSV 导入产品;存在则更新,不存在则创建 - async importProductsCSV(file: any): Promise<{ created: number; updated: number; errors: string[] }> { + async importProductsCSV(file: any): Promise { let buffer: Buffer; if (Buffer.isBuffer(file)) { buffer = file; @@ -1676,19 +1691,19 @@ export class ProductService { console.log('First record keys:', Object.keys(records[0])); } } catch (e: any) { - return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] }; + throw new Error(`CSV 解析失败:${e?.message || e}`) } let created = 0; let updated = 0; - const errors: string[] = []; + const errors: BatchErrorItem[] = []; // 逐条处理记录 for (const rec of records) { try { const data = this.transformCsvRecordToData(rec); if (!data) { - errors.push('缺少 SKU 的记录已跳过'); + errors.push({ identifier: data.sku, error: '缺少 SKU 的记录已跳过'}); continue; } const { sku } = data; @@ -1708,11 +1723,11 @@ export class ProductService { updated += 1; } } catch (e: any) { - errors.push(`产品${rec?.sku}导入失败:${e?.message || String(e)}`); + errors.push({ identifier: '' + rec.sku, error: `产品${rec?.sku}导入失败:${e?.message || String(e)}`}); } } - return { created, updated, errors }; + return { total: records.length, processed: records.length - errors.length, created, updated, errors }; } // 将库存记录的 sku 添加到产品单品中 @@ -1831,9 +1846,7 @@ export class ProductService { } // 将本地产品转换为站点API所需格式 - const unifiedProduct = await this.convertLocalProductToUnifiedProduct(localProduct, params.siteSku); - - + const unifiedProduct = await this.mapLocalToUnifiedProduct(localProduct, params.siteSku); // 调用站点API的upsertProduct方法 try { @@ -1842,7 +1855,7 @@ export class ProductService { await this.bindSiteSkus(localProduct.id, [unifiedProduct.sku]); return result; } catch (error) { - throw new Error(`同步产品到站点失败: ${error.message}`); + throw new Error(`同步产品到站点失败: ${error?.response?.data?.message??error.message}`); } } @@ -1869,9 +1882,6 @@ export class ProductService { siteSku: item.siteSku }); - // 然后绑定站点SKU - await this.bindSiteSkus(item.productId, [item.siteSku]); - results.synced++; results.processed++; } catch (error) { @@ -1892,30 +1902,23 @@ export class ProductService { * @param siteProductId 站点产品ID * @returns 同步后的本地产品 */ - async syncProductFromSite(siteId: number, siteProductId: string | number): Promise { + async syncProductFromSite(siteId: number, siteProductId: string | number, sku: string): Promise { + const adapter = await this.siteApiService.getAdapter(siteId); + const siteProduct = await adapter.getProduct({ id: siteProductId }); // 从站点获取产品信息 - const siteProduct = await this.siteApiService.getProductFromSite(siteId, siteProductId); if (!siteProduct) { throw new Error(`站点产品 ID ${siteProductId} 不存在`); } - - // 检查是否已存在相同SKU的本地产品 - let localProduct = null; - if (siteProduct.sku) { - try { - localProduct = await this.findProductBySku(siteProduct.sku); - } catch (error) { - // 产品不存在,继续创建 - } - } - // 将站点产品转换为本地产品格式 - const productData = await this.convertSiteProductToLocalProduct(siteProduct); - - if (localProduct) { + const productData = await this.mapUnifiedToLocalProduct(siteProduct); + return await this.upsertProduct({sku}, productData); + } + async upsertProduct(where: Partial>, productData: any) { + const existingProduct = await this.productModel.findOne({ where: where}); + if (existingProduct) { // 更新现有产品 const updateData: UpdateProductDTO = productData; - return await this.updateProduct(localProduct.id, updateData); + return await this.updateProduct(existingProduct.id, updateData); } else { // 创建新产品 const createData: CreateProductDTO = productData; @@ -1929,18 +1932,18 @@ export class ProductService { * @param siteProductIds 站点产品ID数组 * @returns 批量同步结果 */ - async batchSyncFromSite(siteId: number, siteProductIds: (string | number)[]): Promise<{ synced: number, errors: string[] }> { + async batchSyncFromSite(siteId: number, data: Array<{siteProductId:string, sku: string}>): Promise<{ synced: number, errors: string[] }> { const results = { synced: 0, errors: [] }; - for (const siteProductId of siteProductIds) { + for (const item of data) { try { - await this.syncProductFromSite(siteId, siteProductId); + await this.syncProductFromSite(siteId, item.siteProductId, item.sku); results.synced++; } catch (error) { - results.errors.push(`站点产品ID ${siteProductId} 同步失败: ${error.message}`); + results.errors.push(`站点产品ID ${item.siteProductId} 同步失败: ${error.message}`); } } @@ -1952,7 +1955,7 @@ export class ProductService { * @param siteProduct 站点产品对象 * @returns 本地产品数据 */ - private async convertSiteProductToLocalProduct(siteProduct: any): Promise { + private async mapUnifiedToLocalProduct(siteProduct: any): Promise { const productData: any = { sku: siteProduct.sku, name: siteProduct.name, @@ -2015,18 +2018,20 @@ export class ProductService { * @param localProduct 本地产品对象 * @returns 统一产品对象 */ - private async convertLocalProductToUnifiedProduct(localProduct: Product,siteSku?: string): Promise> { + private async mapLocalToUnifiedProduct(localProduct: Product,siteSku?: string): Promise> { + const tags = localProduct.attributes?.map(a => ({name: a.name})) || []; // 将本地产品数据转换为UnifiedProductDTO格式 const unifiedProduct: any = { id: localProduct.id ? String(localProduct.id) : undefined, // 如果产品已存在,使用现有ID - name: localProduct.nameCn || localProduct.name || localProduct.sku, - type: 'simple', // 默认类型,可以根据实际需要调整 + name: localProduct.name, + type: localProduct.type === 'single'? 'simple' : 'bundle', // 默认类型,可以根据实际需要调整 status: 'publish', // 默认状态,可以根据实际需要调整 - sku: siteSku || await this.templateService.render('site.product.sku', { sku: localProduct.sku }), + sku: siteSku || await this.templateService.render('site.product.sku', { product: localProduct, sku: localProduct.sku }), regular_price: String(localProduct.price || 0), sale_price: String(localProduct.promotionPrice || localProduct.price || 0), price: String(localProduct.price || 0), - // stock_status: localProduct.stockQuantity && localProduct.stockQuantity > 0 ? 'instock' : 'outofstock', + // TODO 库存暂时无法同步 + // stock_status: localProduct.components && localProduct.stockQuantity > 0 ? 'instock' : 'outofstock', // stock_quantity: localProduct.stockQuantity || 0, // images: localProduct.images ? localProduct.images.map(img => ({ // id: img.id, @@ -2034,25 +2039,24 @@ export class ProductService { // name: img.name || '', // alt: img.alt || '' // })) : [], - tags: [], + tags, categories: localProduct.category ? [{ id: localProduct.category.id, name: localProduct.category.name }] : [], attributes: localProduct.attributes ? localProduct.attributes.map(attr => ({ - id: attr.id, - name: attr.name, - position: 0, + id: attr.dict.id, + name: attr.dict.name, + position: attr.dict.sort || 0, visible: true, variation: false, - options: [attr.value] + options: [attr.name] })) : [], variations: [], date_created: localProduct.createdAt ? new Date(localProduct.createdAt).toISOString() : new Date().toISOString(), date_modified: localProduct.updatedAt ? new Date(localProduct.updatedAt).toISOString() : new Date().toISOString(), raw: { - localProductId: localProduct.id, - localProductSku: localProduct.sku + ...localProduct } }; diff --git a/src/service/site-api.service.ts b/src/service/site-api.service.ts index 3aab4ac..002536d 100644 --- a/src/service/site-api.service.ts +++ b/src/service/site-api.service.ts @@ -39,7 +39,7 @@ export class SiteApiService { } return new ShopyyAdapter(site, this.shopyyService); } - + throw new Error(`Unsupported site type: ${site.type}`); } @@ -57,7 +57,7 @@ export class SiteApiService { try { // 使用站点SKU查询对应的ERP产品 const erpProduct = await this.productService.findProductBySiteSku(siteProduct.sku); - + // 将ERP产品信息合并到站点商品中 return { ...siteProduct, @@ -108,24 +108,20 @@ export class SiteApiService { */ async upsertProduct(siteId: number, product: Partial): Promise { const adapter = await this.getAdapter(siteId); - + // 首先尝试查找产品 - if (product.sku) { - // 如果没有提供ID但提供了SKU,尝试通过SKU查找产品 - try { - // 尝试搜索具有相同SKU的产品 - const existingProduct = await adapter.getProduct( { sku: product.sku }); - if (existingProduct) { - // 找到现有产品,更新它 - return await adapter.updateProduct({ id: existingProduct.id }, product); - } - // 产品不存在,执行创建 - return await adapter.createProduct(product); - } catch (error) { - // 搜索失败,继续执行创建逻辑 - console.log(`通过SKU搜索产品失败:`, error.message); - } + if (!product.sku) { + throw new Error('产品SKU不能为空'); } + // 尝试搜索具有相同SKU的产品 + const existingProduct = await adapter.getProduct({ sku: product.sku }); + if (existingProduct) { + // 找到现有产品,更新它 + return await adapter.updateProduct({ id: existingProduct.id }, product); + } + // 产品不存在,执行创建 + return await adapter.createProduct(product); + } /** @@ -175,17 +171,6 @@ export class SiteApiService { return await adapter.getProducts(params); } - /** - * 从站点获取单个产品 - * @param siteId 站点ID - * @param productId 产品ID - * @returns 站点产品 - */ - async getProductFromSite(siteId: number, productId: string | number): Promise { - const adapter = await this.getAdapter(siteId); - return await adapter.getProduct({ id: productId }); - } - /** * 从站点获取所有产品 * @param siteId 站点ID diff --git a/src/service/wp.service.ts b/src/service/wp.service.ts index 8c8bc89..14cf044 100644 --- a/src/service/wp.service.ts +++ b/src/service/wp.service.ts @@ -44,7 +44,7 @@ export class WPService implements IPlatformService { * @param site 站点配置 * @param namespace API 命名空间,默认 wc/v3;订阅推荐 wcs/v1 */ - private createApi(site: any, namespace: WooCommerceRestApiVersion = 'wc/v3') { + public createApi(site: any, namespace: WooCommerceRestApiVersion = 'wc/v3') { return new WooCommerceRestApi({ url: site.apiUrl, consumerKey: site.consumerKey,