diff --git a/src/adapter/shopyy.adapter.ts b/src/adapter/shopyy.adapter.ts new file mode 100644 index 0000000..8db04ac --- /dev/null +++ b/src/adapter/shopyy.adapter.ts @@ -0,0 +1,286 @@ +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { ShopyyService } from '../service/shopyy.service'; +import { + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedCustomerDTO, +} from '../dto/site-api.dto'; + +export class ShopyyAdapter implements ISiteAdapter { + constructor(private site: any, private shopyyService: ShopyyService) { } + // private mapProductStatus(status: number){ + // return status === 1 ? 'publish' : 'draft'; + // } + + private mapProduct(item: any): UnifiedProductDTO { + function mapProductStatus(status: number) { + return status === 1 ? 'publish' : 'draft'; + } + return { + id: item.id, + name: item.name || item.title, + type: item.product_type, + status: mapProductStatus(item.status), + sku: item.variant?.sku || '', + regular_price: item.variant?.price, + sale_price: item.special_price, + price: item.price, + stock_status: item.inventory_tracking === 1 ? 'instock' : 'outofstock', + stock_quantity: item.inventory_quantity, + images: (item.images || []).map((img: any) => ({ + id: img.id || 0, + src: img.src, + name: '', + alt: img.alt || '', + // 排序 + position: img.position || '' + })), + attributes: [], + tags: item.tags || [], + variations: item.variants?.map(this.mapVariation.bind(this)) || [], + date_created: item.created_at, + date_modified: item.updated_at, + raw: item, + }; + } + mapVariation(mapVariation: any) { + return { + id: mapVariation.id, + sku: mapVariation.sku || '', + regular_price: mapVariation.price, + sale_price: mapVariation.special_price, + price: mapVariation.price, + stock_status: mapVariation.inventory_tracking === 1 ? 'instock' : 'outofstock', + stock_quantity: mapVariation.inventory_quantity, + } + } + + private mapOrder(item: any): UnifiedOrderDTO { + return { + id: item.order_id, + number: item.order_sn, + status: item.order_status, + currency: item.currency, + total: item.total_amount, + customer_id: item.user_id, + customer_name: `${item.firstname} ${item.lastname}`.trim(), + email: item.email, + line_items: (item.products || []).map((p: any) => ({ + id: p.id, + name: p.name, + product_id: p.product_id, + quantity: p.quantity, + total: p.price, + sku: p.sku + })), + sales: (item.products || []).map((p: any) => ({ + id: p.id, + name: p.name, + product_id: p.product_id, + productId: p.product_id, + quantity: p.quantity, + total: p.price, + sku: p.sku + })), + billing: { + first_name: item.firstname, + last_name: item.lastname, + email: item.email, + phone: item.telephone, + address_1: item.payment_address, + city: item.payment_city, + state: item.payment_zone, + postcode: item.payment_postcode, + country: item.payment_country + }, + shipping: { + first_name: item.firstname, + last_name: item.lastname, + address_1: item.shipping_address, + city: item.shipping_city, + state: item.shipping_zone, + postcode: item.shipping_postcode, + country: item.shipping_country + }, + payment_method: item.payment_method, + date_created: item.date_added, + raw: item, + }; + } + + private mapCustomer(item: any): UnifiedCustomerDTO { + return { + id: item.customer_id, + first_name: item.firstname, + last_name: item.lastname, + fullname: item.customer_name, + email: item.customer_email, + phone: item.phone || '', + billing: { + first_name: item.first_name, + last_name: item.last_name, + fullname: item.name, + email: item.email, + phone: item.phone || '', + address_1: item.address_1 || '', + city: item.city || '', + state: item.zone || '', + postcode: item.postcode || '', + country: item.country || '' + }, + shipping: { + first_name: item.firstname, + last_name: item.lastname, + address_1: item.address_1 || '', + city: item.city || '', + state: item.zone || '', + postcode: item.postcode || '', + country: item.country || '' + }, + raw: item, + }; + } + + async getProducts( + params: UnifiedSearchParamsDTO + ): Promise> { + const response = await this.shopyyService.fetchResourcePaged( + this.site, + 'products/list', + params + ); + const { items, total, totalPages, page, per_page } = response; + return { + items: items.map(this.mapProduct.bind(this)), + total, + totalPages, + page, + per_page, + }; + } + + async getProduct(id: string | number): Promise { + // 使用ShopyyService获取单个产品 + const product = await this.shopyyService.getProduct(this.site, id); + return this.mapProduct(product); + } + + async createProduct(data: Partial): Promise { + const res = await this.shopyyService.createProduct(this.site, data); + return this.mapProduct(res); + } + + async updateProduct(id: string | number, data: Partial): Promise { + // Shopyy update returns boolean? + // shopyyService.updateProduct returns boolean. + // So I can't return the updated product. + // I have to fetch it again or return empty/input. + // Since getProduct is missing, I'll return input data as UnifiedProductDTO (mock). + const success = await this.shopyyService.updateProduct(this.site, String(id), data); + if (!success) throw new Error('Update failed'); + return { ...data, id } as UnifiedProductDTO; + } + + async updateVariation(productId: string | number, variationId: string | number, data: any): Promise { + const success = await this.shopyyService.updateVariation(this.site, String(productId), String(variationId), data); + if (!success) throw new Error('Update variation failed'); + return { ...data, id: variationId }; + } + + async getOrderNotes(orderId: string | number): Promise { + return await this.shopyyService.getOrderNotes(this.site, orderId); + } + + async createOrderNote(orderId: string | number, data: any): Promise { + return await this.shopyyService.createOrderNote(this.site, orderId, data); + } + + async deleteProduct(id: string | number): Promise { + // Use batch delete + await this.shopyyService.batchProcessProducts(this.site, { delete: [id] }); + return true; + } + + async getOrders( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages, page, per_page } = + await this.shopyyService.fetchResourcePaged( + this.site, + 'orders', + params + ); + return { + items: items.map(this.mapOrder.bind(this)), + total, + totalPages, + page, + per_page, + }; + } + + async getOrder(id: string | number): Promise { + const data = await this.shopyyService.getOrder(String(this.site.id), String(id)); + return this.mapOrder(data); + } + + async createOrder(data: Partial): Promise { + const createdOrder = await this.shopyyService.createOrder(this.site, data); + return this.mapOrder(createdOrder); + } + + async updateOrder(id: string | number, data: Partial): Promise { + return await this.shopyyService.updateOrder(this.site, String(id), data); + } + + async deleteOrder(id: string | number): Promise { + return await this.shopyyService.deleteOrder(this.site, id); + } + + async getSubscriptions( + params: UnifiedSearchParamsDTO + ): Promise> { + throw new Error('Shopyy does not support subscriptions.'); + } + + async getMedia( + params: UnifiedSearchParamsDTO + ): Promise> { + throw new Error('Shopyy does not support media API.'); + } + + async getCustomers(params: UnifiedSearchParamsDTO): Promise> { + const { items, total, totalPages, page, per_page } = + await this.shopyyService.fetchCustomersPaged(this.site, params); + return { + items: items.map(this.mapCustomer.bind(this)), + total, + totalPages, + page, + per_page + }; + } + + async getCustomer(id: string | number): Promise { + const customer = await this.shopyyService.getCustomer(this.site, id); + return this.mapCustomer(customer); + } + + async createCustomer(data: Partial): Promise { + const createdCustomer = await this.shopyyService.createCustomer(this.site, data); + return this.mapCustomer(createdCustomer); + } + + async updateCustomer(id: string | number, data: Partial): Promise { + const updatedCustomer = await this.shopyyService.updateCustomer(this.site, id, data); + return this.mapCustomer(updatedCustomer); + } + + async deleteCustomer(id: string | number): Promise { + return await this.shopyyService.deleteCustomer(this.site, id); + } +} diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts new file mode 100644 index 0000000..a2aeb4b --- /dev/null +++ b/src/adapter/woocommerce.adapter.ts @@ -0,0 +1,286 @@ +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { WPService } from '../service/wp.service'; +import { + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedCustomerDTO, +} from '../dto/site-api.dto'; + +export class WooCommerceAdapter implements ISiteAdapter { + constructor(private site: any, private wpService: WPService) {} + + private mapProduct(item: any): UnifiedProductDTO { + return { + id: item.id, + name: item.name, + type: item.type, + status: item.status, + sku: item.sku, + 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) => ({ + id: img.id, + src: img.src, + name: img.name, + alt: img.alt, + })), + attributes: item.attributes, + variations: item.variations, + date_created: item.date_created, + date_modified: item.date_modified, + raw: item, + }; + } + + private mapOrder(item: any): UnifiedOrderDTO { + return { + id: item.id, + number: item.number, + status: item.status, + currency: item.currency, + total: item.total, + customer_id: item.customer_id, + customer_name: `${item.billing?.first_name || ''} ${ + item.billing?.last_name || '' + }`.trim(), + email: item.billing?.email || '', + line_items: item.line_items, + sales: (item.line_items || []).map((li: any) => ({ + ...li, + productId: li.product_id, + // Ensure other fields match frontend expectation if needed + })), + billing: item.billing, + shipping: item.shipping, + payment_method: item.payment_method_title, + date_created: item.date_created, + raw: item, + }; + } + + private mapSubscription(item: any): UnifiedSubscriptionDTO { + return { + id: item.id, + status: item.status, + customer_id: item.customer_id, + billing_period: item.billing_period, + billing_interval: item.billing_interval, + start_date: item.start_date, + next_payment_date: item.next_payment_date, + line_items: item.line_items, + raw: item, + }; + } + + private mapMedia(item: any): UnifiedMediaDTO { + return { + id: item.id, + title: item.title?.rendered || '', + media_type: item.media_type, + mime_type: item.mime_type, + source_url: item.source_url, + date_created: item.date_created, + }; + } + + async getProducts( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged( + this.site, + 'products', + params + ); + return { + items: items.map(this.mapProduct), + total, + totalPages, + page, + per_page, + }; + } + + async getProduct(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`products/${id}`); + return this.mapProduct(res.data); + } + + async createProduct(data: Partial): Promise { + const res = await this.wpService.createProduct(this.site, data); + return this.mapProduct(res); + } + + async updateProduct(id: string | number, data: Partial): Promise { + const res = await this.wpService.updateProduct(this.site, String(id), data as any); + return this.mapProduct(res); + } + + async updateVariation(productId: string | number, variationId: string | number, data: any): Promise { + const res = await this.wpService.updateVariation(this.site, String(productId), String(variationId), data); + return res; + } + + async getOrderNotes(orderId: string | number): Promise { + const api = (this.wpService as any).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 res = await api.post(`orders/${orderId}/notes`, data); + return res.data; + } + + async deleteProduct(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + try { + await api.delete(`products/${id}`, { force: true }); + return true; + } catch (e) { + return false; + } + } + + async getOrders( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged(this.site, 'orders', params); + return { + items: items.map(this.mapOrder), + total, + totalPages, + page, + per_page, + }; + } + + async getOrder(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`orders/${id}`); + return this.mapOrder(res.data); + } + + async createOrder(data: Partial): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.post('orders', data); + return this.mapOrder(res.data); + } + + async updateOrder(id: string | number, data: Partial): Promise { + return await this.wpService.updateOrder(this.site, String(id), data as any); + } + + async deleteOrder(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + await api.delete(`orders/${id}`, { force: true }); + return true; + } + + async getSubscriptions( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged( + this.site, + 'subscriptions', + params + ); + return { + items: items.map(this.mapSubscription), + total, + totalPages, + page, + per_page, + }; + } + + async getMedia( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages } = await this.wpService.getMedia( + this.site.id, + params.page || 1, + params.per_page || 20 + ); + return { + items: items.map(this.mapMedia), + total, + totalPages, + page: params.page || 1, + per_page: params.per_page || 20, + }; + } + + async deleteMedia(id: string | number): Promise { + await this.wpService.deleteMedia(Number(this.site.id), Number(id), true); + return true; + } + + async updateMedia(id: string | number, data: any): Promise { + return await this.wpService.updateMedia(Number(this.site.id), Number(id), data); + } + + private mapCustomer(item: any): UnifiedCustomerDTO { + return { + id: item.id, + email: item.email, + first_name: item.first_name, + last_name: item.last_name, + username: item.username, + phone: item.billing?.phone || item.shipping?.phone, + billing: item.billing, + shipping: item.shipping, + raw: item, + }; + } + + async getCustomers(params: UnifiedSearchParamsDTO): Promise> { + const { items, total, totalPages, page, per_page } = await this.wpService.fetchResourcePaged( + this.site, + 'customers', + params + ); + return { + items: items.map((i: any) => this.mapCustomer(i)), + total, + totalPages, + page, + per_page, + }; + } + + async getCustomer(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`customers/${id}`); + return this.mapCustomer(res.data); + } + + async createCustomer(data: Partial): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.post('customers', data); + return this.mapCustomer(res.data); + } + + async updateCustomer(id: string | number, data: Partial): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.put(`customers/${id}`, data); + return this.mapCustomer(res.data); + } + + async deleteCustomer(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + await api.delete(`customers/${id}`, { force: true }); + return true; + } +} diff --git a/src/config/config.local.ts b/src/config/config.local.ts index e839182..0c962ce 100644 --- a/src/config/config.local.ts +++ b/src/config/config.local.ts @@ -39,7 +39,7 @@ export default { wpApiUrl: "http://simple.local", consumerKey: 'ck_11b446d0dfd221853830b782049cf9a17553f886', consumerSecret: 'cs_2b06729269f659dcef675b8cdff542bf3c1da7e8', - siteName: 'LocalSimple', + name: 'LocalSimple', email: '2469687281@qq.com', emailPswd: 'lulin91.', }, @@ -48,7 +48,7 @@ export default { // wpApiUrl: 'http://t2-shop.local/', // consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742', // consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c', - // siteName: 'Local', + // name: 'Local', // email: '2469687281@qq.com', // emailPswd: 'lulin91.', // }, @@ -57,7 +57,7 @@ export default { // wpApiUrl: 'http://t1-shop.local/', // consumerKey: 'ck_a369473a6451dbaec63d19cbfd74a074b2c5f742', // consumerSecret: 'cs_0946bbbeea1bfefff08a69e817ac62a48412df8c', - // siteName: 'Local-test-2', + // name: 'Local-test-2', // email: '2469687281@qq.com', // emailPswd: 'lulin91.', // }, @@ -66,7 +66,7 @@ export default { // wpApiUrl: 'http://localhost:10004', // consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652', // consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38', - // siteName: 'Local', + // name: 'Local', // email: 'tom@yoonevape.com', // emailPswd: 'lulin91.', // }, diff --git a/src/controller/customer.controller.ts b/src/controller/customer.controller.ts index 151d179..f96a30c 100644 --- a/src/controller/customer.controller.ts +++ b/src/controller/customer.controller.ts @@ -1,82 +1,26 @@ -import { - Body, - Context, - Controller, - Del, - Get, - Inject, - Post, - Put, - Query, -} from '@midwayjs/core'; -import { CustomerService } from '../service/customer.service'; -import { errorResponse, successResponse } from '../utils/response.util'; -import { ApiOkResponse } from '@midwayjs/swagger'; -import { BooleanRes } from '../dto/reponse.dto'; -import { CustomerTagDTO, QueryCustomerListDTO } from '../dto/customer.dto'; +import { Controller, Get, Inject, Query } from '@midwayjs/core'; +import { WPService } from '../service/wp.service'; +import { successResponse, errorResponse } from '../utils/response.util'; @Controller('/customer') export class CustomerController { @Inject() - ctx: Context; + wpService: WPService; - @Inject() - customerService: CustomerService; - - @ApiOkResponse() @Get('/list') - async getCustomerList(@Query() param: QueryCustomerListDTO) { + async list( + @Query('siteId') siteId: number, + @Query('page') page: number = 1, + @Query('pageSize') pageSize: number = 20 + ) { try { - const data = await this.customerService.getCustomerList(param); - return successResponse(data); + if (!siteId) { + return errorResponse('siteId is required'); + } + const result = await this.wpService.getCustomers(siteId, page, pageSize); + return successResponse(result); } catch (error) { - console.log(error) - return errorResponse(error?.message || error); - } - } - - @ApiOkResponse({ type: BooleanRes }) - @Post('/tag/add') - async addTag(@Body() dto: CustomerTagDTO) { - try { - await this.customerService.addTag(dto.email, dto.tag); - return successResponse(true); - } catch (error) { - return errorResponse(error?.message || error); - } - } - - @ApiOkResponse({ type: BooleanRes }) - @Del('/tag/del') - async delTag(@Body() dto: CustomerTagDTO) { - try { - await this.customerService.delTag(dto.email, dto.tag); - return successResponse(true); - } catch (error) { - return errorResponse(error?.message || error); - } - } - - @ApiOkResponse() - @Get('/tags') - async getTags() { - try { - const data = await this.customerService.getTags(); - return successResponse(data); - } catch (error) { - return errorResponse(error?.message || error); - } - } - - - @ApiOkResponse({ type: BooleanRes }) - @Put('/rate') - async setRate(@Body() params: { id: number; rate: number }) { - try { - await this.customerService.setRate(params); - return successResponse(true); - } catch (error) { - return errorResponse(error?.message || error); + return errorResponse(error.message); } } } diff --git a/src/controller/media.controller.ts b/src/controller/media.controller.ts index 628f62a..cafd85f 100644 --- a/src/controller/media.controller.ts +++ b/src/controller/media.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Inject, Query } from '@midwayjs/core'; +import { Controller, Get, Inject, Query, Post, Del, Param, Files, Fields, Body } from '@midwayjs/core'; import { WPService } from '../service/wp.service'; import { successResponse, errorResponse } from '../utils/response.util'; @@ -23,4 +23,57 @@ export class MediaController { return errorResponse(error.message); } } + + @Post('/upload') + async upload(@Fields() fields, @Files() files) { + try { + const siteId = fields.siteId; + if (!siteId) { + return errorResponse('siteId is required'); + } + if (!files || files.length === 0) { + return errorResponse('file is required'); + } + const file = files[0]; + const result = await this.wpService.createMedia(siteId, file); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } + + @Post('/update/:id') + async update(@Param('id') id: number, @Body() body) { + try { + const siteId = body.siteId; + if (!siteId) { + return errorResponse('siteId is required'); + } + // 过滤出需要更新的字段 + const { title, caption, description, alt_text } = body; + const data: any = {}; + if (title !== undefined) data.title = title; + if (caption !== undefined) data.caption = caption; + if (description !== undefined) data.description = description; + if (alt_text !== undefined) data.alt_text = alt_text; + + const result = await this.wpService.updateMedia(siteId, id, data); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } + + @Del('/:id') + async delete(@Param('id') id: number, @Query('siteId') siteId: number, @Query('force') force: boolean = true) { + try { + if (!siteId) { + return errorResponse('siteId is required'); + } + const result = await this.wpService.deleteMedia(siteId, id, force); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } } diff --git a/src/controller/order.controller.ts b/src/controller/order.controller.ts index 40f5026..e5dc3c6 100644 --- a/src/controller/order.controller.ts +++ b/src/controller/order.controller.ts @@ -38,8 +38,8 @@ export class OrderController { @Post('/syncOrder/:siteId') async syncOrder(@Param('siteId') siteId: number) { try { - await this.orderService.syncOrders(siteId); - return successResponse(true); + const result = await this.orderService.syncOrders(siteId); + return successResponse(result); } catch (error) { console.log(error); return errorResponse('同步失败'); diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 44c6066..6ba487a 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -11,7 +11,7 @@ import { } from '@midwayjs/core'; import { ProductService } from '../service/product.service'; import { errorResponse, successResponse } from '../utils/response.util'; -import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, BatchUpdateProductDTO } from '../dto/product.dto'; +import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, BatchUpdateProductDTO, BatchDeleteProductDTO } from '../dto/product.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; import { ContentType, Files } from '@midwayjs/core'; @@ -145,6 +145,20 @@ export class ProductController { } } + @ApiOkResponse({ type: BooleanRes }) + @Post('/batch-delete') + async batchDeleteProduct(@Body() body: BatchDeleteProductDTO) { + try { + const result = await this.productService.batchDeleteProduct(body.ids); + if (result.failed > 0) { + return errorResponse(`成功删除 ${result.success} 个,失败 ${result.failed} 个。首个错误: ${result.errors[0]}`); + } + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + @ApiOkResponse({ type: ProductRes }) @Put('updateNameCn/:id/:nameCn') async updatenameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) { @@ -623,4 +637,16 @@ export class ProductController { return errorResponse(error?.message || error); } } + + // 同步库存 SKU 到产品单品 + @ApiOkResponse({ description: '同步库存 SKU 到产品单品' }) + @Post('/sync-stock') + async syncStockToProduct() { + try { + const data = await this.productService.syncStockToProduct(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } } diff --git a/src/controller/site-api.controller.ts b/src/controller/site-api.controller.ts new file mode 100644 index 0000000..e05ec7d --- /dev/null +++ b/src/controller/site-api.controller.ts @@ -0,0 +1,437 @@ +import { Controller, Get, Inject, Param, Query, Body, Post, Put, Del } from '@midwayjs/core'; +import { ApiOkResponse } from '@midwayjs/swagger'; +import { + UnifiedMediaPaginationDTO, + UnifiedOrderDTO, + UnifiedOrderPaginationDTO, + UnifiedProductDTO, + UnifiedProductPaginationDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionPaginationDTO, + UnifiedCustomerDTO, + UnifiedCustomerPaginationDTO, +} from '../dto/site-api.dto'; +import { SiteApiService } from '../service/site-api.service'; +import { errorResponse, successResponse } from '../utils/response.util'; +import { ILogger } from '@midwayjs/core'; + + +@Controller('/site-api') +export class SiteApiController { + @Inject() + siteApiService: SiteApiService; + + @Inject() + logger: ILogger; + + @Get('/:siteId/products') + @ApiOkResponse({ type: UnifiedProductPaginationDTO }) + async getProducts( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取产品列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getProducts(query); + this.logger.info(`[Site API] 获取产品列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个产品`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取产品列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/products/:id') + @ApiOkResponse({ type: UnifiedProductDTO }) + async getProduct( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getProduct(id); + this.logger.info(`[Site API] 获取单个产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/products') + @ApiOkResponse({ type: UnifiedProductDTO }) + async createProduct( + @Param('siteId') siteId: number, + @Body() body: UnifiedProductDTO + ) { + this.logger.info(`[Site API] 创建产品开始, siteId: ${siteId}, 产品名称: ${body.name}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createProduct(body); + this.logger.info(`[Site API] 创建产品成功, siteId: ${siteId}, 产品ID: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建产品失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/products/:id') + @ApiOkResponse({ type: UnifiedProductDTO }) + async updateProduct( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: UnifiedProductDTO + ) { + this.logger.info(`[Site API] 更新产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateProduct(id, body); + this.logger.info(`[Site API] 更新产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/products/:productId/variations/:variationId') + @ApiOkResponse({ type: Object }) + async updateVariation( + @Param('siteId') siteId: number, + @Param('productId') productId: string, + @Param('variationId') variationId: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新产品变体开始, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateVariation(productId, variationId, body); + this.logger.info(`[Site API] 更新产品变体成功, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新产品变体失败, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/products/:id') + @ApiOkResponse({ type: Boolean }) + async deleteProduct( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const success = await adapter.deleteProduct(id); + this.logger.info(`[Site API] 删除产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(success); + } catch (error) { + this.logger.error(`[Site API] 删除产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders') + @ApiOkResponse({ type: UnifiedOrderPaginationDTO }) + async getOrders( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取订单列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getOrders(query); + this.logger.info(`[Site API] 获取订单列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个订单`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订单列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders/:id') + @ApiOkResponse({ type: UnifiedOrderDTO }) + async getOrder( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getOrder(id); + this.logger.info(`[Site API] 获取单个订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders') + @ApiOkResponse({ type: UnifiedOrderDTO }) + async createOrder( + @Param('siteId') siteId: number, + @Body() body: any + ) { + this.logger.info(`[Site API] 创建订单开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createOrder(body); + this.logger.info(`[Site API] 创建订单成功, siteId: ${siteId}, orderId: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建订单失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/orders/:id') + @ApiOkResponse({ type: Boolean }) + async updateOrder( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const ok = await adapter.updateOrder(id, body); + this.logger.info(`[Site API] 更新订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(ok); + } catch (error) { + this.logger.error(`[Site API] 更新订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/orders/:id') + @ApiOkResponse({ type: Boolean }) + async deleteOrder( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const ok = await adapter.deleteOrder(id); + this.logger.info(`[Site API] 删除订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(ok); + } catch (error) { + this.logger.error(`[Site API] 删除订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders/:id/notes') + @ApiOkResponse({ type: Object }) + async getOrderNotes( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取订单备注开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getOrderNotes(id); + this.logger.info(`[Site API] 获取订单备注成功, siteId: ${siteId}, orderId: ${id}, 共获取到 ${data.length} 条备注`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订单备注失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/:id/notes') + @ApiOkResponse({ type: Object }) + async createOrderNote( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 创建订单备注开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createOrderNote(id, body); + this.logger.info(`[Site API] 创建订单备注成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建订单备注失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/subscriptions') + @ApiOkResponse({ type: UnifiedSubscriptionPaginationDTO }) + async getSubscriptions( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取订阅列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getSubscriptions(query); + this.logger.info(`[Site API] 获取订阅列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个订阅`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订阅列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/media') + @ApiOkResponse({ type: UnifiedMediaPaginationDTO }) + async getMedia( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取媒体列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getMedia(query); + this.logger.info(`[Site API] 获取媒体列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个媒体`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取媒体列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/media/:id') + @ApiOkResponse({ type: Boolean }) + async deleteMedia( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除媒体开始, siteId: ${siteId}, mediaId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const api: any = adapter as any; + if (api.deleteMedia) { + const success = await api.deleteMedia(id); + this.logger.info(`[Site API] 删除媒体成功, siteId: ${siteId}, mediaId: ${id}`); + return successResponse(success); + } + throw new Error('Media delete not supported'); + } catch (error) { + this.logger.error(`[Site API] 删除媒体失败, siteId: ${siteId}, mediaId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/media/:id') + @ApiOkResponse({ type: Object }) + async updateMedia( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新媒体开始, siteId: ${siteId}, mediaId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const api: any = adapter as any; + if (api.updateMedia) { + const res = await api.updateMedia(id, body); + this.logger.info(`[Site API] 更新媒体成功, siteId: ${siteId}, mediaId: ${id}`); + return successResponse(res); + } + throw new Error('Media update not supported'); + } catch (error) { + this.logger.error(`[Site API] 更新媒体失败, siteId: ${siteId}, mediaId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers') + @ApiOkResponse({ type: UnifiedCustomerPaginationDTO }) + async getCustomers( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取客户列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getCustomers(query); + this.logger.info(`[Site API] 获取客户列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个客户`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取客户列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers/:id') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async getCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getCustomer(id); + this.logger.info(`[Site API] 获取单个客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/customers') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async createCustomer( + @Param('siteId') siteId: number, + @Body() body: UnifiedCustomerDTO + ) { + this.logger.info(`[Site API] 创建客户开始, siteId: ${siteId}, 客户邮箱: ${body.email}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createCustomer(body); + this.logger.info(`[Site API] 创建客户成功, siteId: ${siteId}, customerId: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建客户失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/customers/:id') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async updateCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: UnifiedCustomerDTO + ) { + this.logger.info(`[Site API] 更新客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateCustomer(id, body); + this.logger.info(`[Site API] 更新客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/customers/:id') + @ApiOkResponse({ type: Boolean }) + async deleteCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const success = await adapter.deleteCustomer(id); + this.logger.info(`[Site API] 删除客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(success); + } catch (error) { + this.logger.error(`[Site API] 删除客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } +} diff --git a/src/controller/subscription.controller.ts b/src/controller/subscription.controller.ts index c9e4537..61059e3 100644 --- a/src/controller/subscription.controller.ts +++ b/src/controller/subscription.controller.ts @@ -15,8 +15,8 @@ export class SubscriptionController { @Post('/sync/:siteId') async sync(@Param('siteId') siteId: number) { try { - await this.subscriptionService.syncSubscriptions(siteId); - return successResponse(true); + const result = await this.subscriptionService.syncSubscriptions(siteId); + return successResponse(result); } catch (error) { return errorResponse(error?.message || '同步失败'); } diff --git a/src/controller/template.controller.ts b/src/controller/template.controller.ts index 6d009e3..aee9850 100644 --- a/src/controller/template.controller.ts +++ b/src/controller/template.controller.ts @@ -128,4 +128,19 @@ export class TemplateController { return errorResponse(error.message); } } + + /** + * @summary 回填缺失的测试数据 + * @description 扫描数据库中所有模板,为缺失 testData 的记录生成并保存测试数据 + */ + @ApiOkResponse({ type: Number, description: '成功回填的数量' }) + @Post('/backfill-testdata') + async backfillTestData() { + try { + const count = await this.templateService.backfillMissingTestData(); + return successResponse({ updated: count }); + } catch (error) { + return errorResponse(error.message); + } + } } diff --git a/src/controller/user.controller.ts b/src/controller/user.controller.ts index 2c583fe..2d8f47f 100644 --- a/src/controller/user.controller.ts +++ b/src/controller/user.controller.ts @@ -63,19 +63,32 @@ export class UserController { isActive?: string; isSuper?: string; isAdmin?: string; + sortField?: string; + sortOrder?: string; } ) { - const { current = 1, pageSize = 10, remark, username, isActive, isSuper, isAdmin } = query; + const { current = 1, pageSize = 10, remark, username, isActive, isSuper, isAdmin, sortField, sortOrder } = query; // 将字符串布尔转换为真实布尔 const toBool = (v?: string) => (v === undefined ? undefined : v === 'true'); + // 处理排序方向 + const order = (sortOrder === 'ascend' || sortOrder === 'ASC') ? 'ASC' : 'DESC'; + // 列表移除密码字段 - const { items, total } = await this.userService.listUsers(current, pageSize, { - remark, - username, - isActive: toBool(isActive), - isSuper: toBool(isSuper), - isAdmin: toBool(isAdmin), - }); + const { items, total } = await this.userService.listUsers( + current, + pageSize, + { + remark, + username, + isActive: toBool(isActive), + isSuper: toBool(isSuper), + isAdmin: toBool(isAdmin), + }, + { + field: sortField, + order, + } + ); const safeItems = (items || []).map((it: any) => { const { password, ...rest } = it || {}; return rest; diff --git a/src/controller/wp_product.controller.ts b/src/controller/wp_product.controller.ts index f945a6d..254db29 100644 --- a/src/controller/wp_product.controller.ts +++ b/src/controller/wp_product.controller.ts @@ -7,6 +7,8 @@ import { Query, Put, Body, + Files, + Del, } from '@midwayjs/core'; import { WpProductService } from '../service/wp_product.service'; import { errorResponse, successResponse } from '../utils/response.util'; @@ -14,9 +16,11 @@ import { ApiOkResponse } from '@midwayjs/swagger'; import { BooleanRes, WpProductListRes } from '../dto/reponse.dto'; import { QueryWpProductDTO, - SetConstitutionDTO, UpdateVariationDTO, UpdateWpProductDTO, + BatchSyncProductsDTO, + BatchUpdateTagsDTO, + BatchUpdateProductsDTO, } from '../dto/wp_product.dto'; import { WPService } from '../service/wp.service'; import { SiteService } from '../service/site.service'; @@ -36,20 +40,93 @@ export class WpProductController { @Inject() private readonly siteService: SiteService; + @ApiOkResponse({ + type: BooleanRes, + }) + @Del('/:id') + async delete(@Param('id') id: number) { + try { + await this.wpProductService.deleteById(id); + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '删除失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/import/:siteId') + async importProducts(@Param('siteId') siteId: number, @Files() files) { + try { + if (!files || files.length === 0) { + throw new Error('请上传文件'); + } + await this.wpProductService.importProducts(siteId, files[0]); + return successResponse(true); + } catch (error) { + console.error('导入失败:', error); + return errorResponse(error.message || '导入失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-update') + async batchUpdateProducts(@Body() body: BatchUpdateProductsDTO) { + try { + await this.wpProductService.batchUpdateProducts(body); + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '批量更新失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-update-tags') + async batchUpdateTags(@Body() body: BatchUpdateTagsDTO) { + try { + await this.wpProductService.batchUpdateTags(body.ids, body.tags); + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '批量更新标签失败'); + } + } + @ApiOkResponse({ type: BooleanRes, }) @Post('/sync/:siteId') async syncProducts(@Param('siteId') siteId: number) { try { - await this.wpProductService.syncSite(siteId); - return successResponse(true); + const result = await this.wpProductService.syncSite(siteId); + return successResponse(result); } catch (error) { console.log(error); return errorResponse('同步失败'); } } + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-sync-to-site/:siteId') + async batchSyncToSite( + @Param('siteId') siteId: number, + @Body() body: BatchSyncProductsDTO + ) { + try { + await this.wpProductService.batchSyncToSite(siteId, body.productIds); + return successResponse(true, '批量同步成功'); + } catch (error) { + console.error('批量同步失败:', error); + return errorResponse(error.message || '批量同步失败'); + } + } + @ApiOkResponse({ type: WpProductListRes, }) @@ -63,24 +140,6 @@ export class WpProductController { } } - @ApiOkResponse({ - type: BooleanRes, - }) - @Put('/:id/constitution') - async setConstitution( - @Param('id') id: number, - @Body() - body: SetConstitutionDTO - ) { - const { isProduct, constitution } = body; - try { - await this.wpProductService.setConstitution(id, isProduct, constitution); - return successResponse(true); - } catch (error) { - return errorResponse(error.message); - } - } - @ApiOkResponse({ type: BooleanRes }) @@ -97,6 +156,44 @@ export class WpProductController { } } + /** + * 创建产品接口 + * @param siteId 站点 ID + * @param body 创建数据 + */ + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/siteId/:siteId/products') + async createProduct( + @Param('siteId') siteId: number, + @Body() body: any + ) { + try { + // 过滤掉前端可能传入的多余字段 + const { fromProductId, ...productData } = body; + + if (productData.type === 'single') { + productData.type = 'simple'; + } + const site = await this.siteService.get(siteId, true); + const result = await this.wpApiService.createProduct( + site, + productData + ); + if (result) { + // 同步回本地数据库 + await this.wpProductService.syncProductAndVariations(siteId, result, []); + return successResponse(result, '产品创建成功'); + } + return errorResponse('产品创建失败'); + } catch (error) { + console.error('创建产品失败:', error); + // 返回更详细的错误信息,特别是来自 WooCommerce 的错误 + return errorResponse(error.response?.data?.message || error.message || '产品创建失败'); + } + } + /** * 更新产品接口 * @param productId 产品 ID @@ -112,6 +209,7 @@ export class WpProductController { @Body() body: UpdateWpProductDTO ) { try { + // ? 这个是啥意思 const isDuplicate = await this.wpProductService.isSkuDuplicate( body.sku, siteId, @@ -122,6 +220,19 @@ export class WpProductController { } const site = await this.siteService.get(siteId, true); + + // Resolve tags + if (body.tags && body.tags.length > 0) { + const resolvedTags = await this.wpApiService.ensureTags(site, body.tags); + (body as any).tags = resolvedTags; + } + + // Resolve categories + if (body.categories && body.categories.length > 0) { + const resolvedCategories = await this.wpApiService.ensureCategories(site, body.categories); + (body as any).categories = resolvedCategories; + } + const result = await this.wpApiService.updateProduct( site, productId, @@ -138,6 +249,19 @@ export class WpProductController { } } + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/sync-to-product/:id') + async syncToProduct(@Param('id') id: number) { + try { + await this.wpProductService.syncToProduct(id); + return successResponse(true); + } catch (error) { + return errorResponse(error.message); + } + } + /** * 更新变体接口 * @param productId 产品 ID diff --git a/src/db/datasource.ts b/src/db/datasource.ts index 3596f3f..8dcfd63 100644 --- a/src/db/datasource.ts +++ b/src/db/datasource.ts @@ -4,7 +4,7 @@ import { SeederOptions } from 'typeorm-extension'; const options: DataSourceOptions & SeederOptions = { type: 'mysql', - host: 'localhost', + host: '127.0.0.1', port: 23306, username: 'root', password: '12345678', diff --git a/src/db/migrations/1765275715762-add_test_data_to_template.ts b/src/db/migrations/1765275715762-add_test_data_to_template.ts new file mode 100644 index 0000000..24956b6 --- /dev/null +++ b/src/db/migrations/1765275715762-add_test_data_to_template.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTestDataToTemplate1765275715762 implements MigrationInterface { + name = 'AddTestDataToTemplate1765275715762' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` DROP FOREIGN KEY \`FK_e93d8c42c9baf5a0dade42c59ae\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`template\` ADD \`testData\` text NULL COMMENT '测试数据JSON'`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` ADD CONSTRAINT \`FK_e93d8c42c9baf5a0dade42c59ae\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` DROP FOREIGN KEY \`FK_e93d8c42c9baf5a0dade42c59ae\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`template\` DROP COLUMN \`testData\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` ADD CONSTRAINT \`FK_e93d8c42c9baf5a0dade42c59ae\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + +} diff --git a/src/db/migrations/1765330208213-add-site-description.ts b/src/db/migrations/1765330208213-add-site-description.ts new file mode 100644 index 0000000..148ce00 --- /dev/null +++ b/src/db/migrations/1765330208213-add-site-description.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSiteDescription1765330208213 implements MigrationInterface { + name = 'AddSiteDescription1765330208213' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + } + +} diff --git a/src/db/seeds/dict.seeder.ts b/src/db/seeds/dict.seeder.ts index 808659e..0201bd7 100644 --- a/src/db/seeds/dict.seeder.ts +++ b/src/db/seeds/dict.seeder.ts @@ -22,77 +22,77 @@ export default class DictSeeder implements Seeder { const dictItemRepository = dataSource.getRepository(DictItem); const flavorsData = [ - { name: 'bellini', title: 'Bellini', titleCn: '贝利尼' }, - { name: 'max-polarmint', title: 'Max Polarmint', titleCn: '马克斯薄荷' }, - { name: 'blueberry', title: 'Blueberry', titleCn: '蓝莓' }, - { name: 'citrus', title: 'Citrus', titleCn: '柑橘' }, - { name: 'wintergreen', title: 'Wintergreen', titleCn: '冬绿薄荷' }, - { name: 'cool-mint', title: 'COOL MINT', titleCn: '清凉薄荷' }, - { name: 'juicy-peach', title: 'JUICY PEACH', titleCn: '多汁蜜桃' }, - { name: 'orange', title: 'ORANGE', titleCn: '橙子' }, - { name: 'peppermint', title: 'PEPPERMINT', titleCn: '胡椒薄荷' }, - { name: 'spearmint', title: 'SPEARMINT', titleCn: '绿薄荷' }, - { name: 'strawberry', title: 'STRAWBERRY', titleCn: '草莓' }, - { name: 'watermelon', title: 'WATERMELON', titleCn: '西瓜' }, - { name: 'coffee', title: 'COFFEE', titleCn: '咖啡' }, - { name: 'lemonade', title: 'LEMONADE', titleCn: '柠檬水' }, - { name: 'apple-mint', title: 'apple mint', titleCn: '苹果薄荷' }, - { name: 'peach', title: 'PEACH', titleCn: '桃子' }, - { name: 'mango', title: 'Mango', titleCn: '芒果' }, - { name: 'ice-wintergreen', title: 'ICE WINTERGREEN', titleCn: '冰冬绿薄荷' }, - { name: 'pink-lemonade', title: 'Pink Lemonade', titleCn: '粉红柠檬水' }, - { name: 'blackcherry', title: 'Blackcherry', titleCn: '黑樱桃' }, - { name: 'fresh-mint', title: 'fresh mint', titleCn: '清新薄荷' }, - { name: 'strawberry-lychee', title: 'Strawberry Lychee', titleCn: '草莓荔枝' }, - { name: 'passion-fruit', title: 'Passion Fruit', titleCn: '百香果' }, - { name: 'banana-lce', title: 'Banana lce', titleCn: '香蕉冰' }, - { name: 'bubblegum', title: 'Bubblegum', titleCn: '泡泡糖' }, - { name: 'mango-lce', title: 'Mango lce', titleCn: '芒果冰' }, - { name: 'grape-lce', title: 'Grape lce', titleCn: '葡萄冰' }, - { name: 'apple', title: 'apple', titleCn: '苹果' }, - { name: 'grape', title: 'grape', titleCn: '葡萄' }, - { name: 'cherry', title: 'cherry', titleCn: '樱桃' }, - { name: 'lemon', title: 'lemon', titleCn: '柠檬' }, - { name: 'razz', title: 'razz', titleCn: '覆盆子' }, - { name: 'pineapple', title: 'pineapple', titleCn: '菠萝' }, - { name: 'berry', title: 'berry', titleCn: '浆果' }, - { name: 'fruit', title: 'fruit', titleCn: '水果' }, - { name: 'mint', title: 'mint', titleCn: '薄荷' }, - { name: 'menthol', title: 'menthol', titleCn: '薄荷醇' }, + { name: 'bellini', title: 'Bellini', titleCn: '贝利尼', shortName: 'BL' }, + { name: 'max-polarmint', title: 'Max Polarmint', titleCn: '马克斯薄荷', shortName: 'MP' }, + { name: 'blueberry', title: 'Blueberry', titleCn: '蓝莓', shortName: 'BB' }, + { name: 'citrus', title: 'Citrus', titleCn: '柑橘', shortName: 'CT' }, + { name: 'wintergreen', title: 'Wintergreen', titleCn: '冬绿薄荷', shortName: 'WG' }, + { name: 'cool-mint', title: 'COOL MINT', titleCn: '清凉薄荷', shortName: 'CM' }, + { name: 'juicy-peach', title: 'JUICY PEACH', titleCn: '多汁蜜桃', shortName: 'JP' }, + { name: 'orange', title: 'ORANGE', titleCn: '橙子', shortName: 'OR' }, + { name: 'peppermint', title: 'PEPPERMINT', titleCn: '胡椒薄荷', shortName: 'PP' }, + { name: 'spearmint', title: 'SPEARMINT', titleCn: '绿薄荷', shortName: 'SM' }, + { name: 'strawberry', title: 'STRAWBERRY', titleCn: '草莓', shortName: 'SB' }, + { name: 'watermelon', title: 'WATERMELON', titleCn: '西瓜', shortName: 'WM' }, + { name: 'coffee', title: 'COFFEE', titleCn: '咖啡', shortName: 'CF' }, + { name: 'lemonade', title: 'LEMONADE', titleCn: '柠檬水', shortName: 'LN' }, + { name: 'apple-mint', title: 'apple mint', titleCn: '苹果薄荷', shortName: 'AM' }, + { name: 'peach', title: 'PEACH', titleCn: '桃子', shortName: 'PC' }, + { name: 'mango', title: 'Mango', titleCn: '芒果', shortName: 'MG' }, + { name: 'ice-wintergreen', title: 'ICE WINTERGREEN', titleCn: '冰冬绿薄荷', shortName: 'IWG' }, + { name: 'pink-lemonade', title: 'Pink Lemonade', titleCn: '粉红柠檬水', shortName: 'PLN' }, + { name: 'blackcherry', title: 'Blackcherry', titleCn: '黑樱桃', shortName: 'BC' }, + { name: 'fresh-mint', title: 'fresh mint', titleCn: '清新薄荷', shortName: 'FM' }, + { name: 'strawberry-lychee', title: 'Strawberry Lychee', titleCn: '草莓荔枝', shortName: 'SBL' }, + { name: 'passion-fruit', title: 'Passion Fruit', titleCn: '百香果', shortName: 'PF' }, + { name: 'banana-lce', title: 'Banana lce', titleCn: '香蕉冰', shortName: 'BI' }, + { name: 'bubblegum', title: 'Bubblegum', titleCn: '泡泡糖', shortName: 'BG' }, + { name: 'mango-lce', title: 'Mango lce', titleCn: '芒果冰', shortName: 'MI' }, + { name: 'grape-lce', title: 'Grape lce', titleCn: '葡萄冰', shortName: 'GI' }, + { name: 'apple', title: 'apple', titleCn: '苹果', shortName: 'AP' }, + { name: 'grape', title: 'grape', titleCn: '葡萄', shortName: 'GR' }, + { name: 'cherry', title: 'cherry', titleCn: '樱桃', shortName: 'CH' }, + { name: 'lemon', title: 'lemon', titleCn: '柠檬', shortName: 'LM' }, + { name: 'razz', title: 'razz', titleCn: '覆盆子', shortName: 'RZ' }, + { name: 'pineapple', title: 'pineapple', titleCn: '菠萝', shortName: 'PA' }, + { name: 'berry', title: 'berry', titleCn: '浆果', shortName: 'BR' }, + { name: 'fruit', title: 'fruit', titleCn: '水果', shortName: 'FR' }, + { name: 'mint', title: 'mint', titleCn: '薄荷', shortName: 'MT' }, + { name: 'menthol', title: 'menthol', titleCn: '薄荷醇', shortName: 'MH' }, ]; const brandsData = [ - { name: 'yoone', title: 'Yoone', titleCn: '' }, - { name: 'white-fox', title: 'White Fox', titleCn: '' }, - { name: 'zyn', title: 'ZYN', titleCn: '' }, - { name: 'zonnic', title: 'Zonnic', titleCn: '' }, - { name: 'zolt', title: 'Zolt', titleCn: '' }, - { name: 'velo', title: 'Velo', titleCn: '' }, - { name: 'lucy', title: 'Lucy', titleCn: '' }, - { name: 'egp', title: 'EGP', titleCn: '' }, - { name: 'bridge', title: 'Bridge', titleCn: '' }, - { name: 'zex', title: 'ZEX', titleCn: '' }, - { name: 'sesh', title: 'Sesh', titleCn: '' }, - { name: 'pablo', title: 'Pablo', titleCn: '' }, + { name: 'yoone', title: 'Yoone', titleCn: '', shortName: 'YN' }, + { name: 'white-fox', title: 'White Fox', titleCn: '', shortName: 'WF' }, + { name: 'zyn', title: 'ZYN', titleCn: '', shortName: 'ZN' }, + { name: 'zonnic', title: 'Zonnic', titleCn: '', shortName: 'ZC' }, + { name: 'zolt', title: 'Zolt', titleCn: '', shortName: 'ZT' }, + { name: 'velo', title: 'Velo', titleCn: '', shortName: 'VL' }, + { name: 'lucy', title: 'Lucy', titleCn: '', shortName: 'LC' }, + { name: 'egp', title: 'EGP', titleCn: '', shortName: 'EP' }, + { name: 'bridge', title: 'Bridge', titleCn: '', shortName: 'BR' }, + { name: 'zex', title: 'ZEX', titleCn: '', shortName: 'ZX' }, + { name: 'sesh', title: 'Sesh', titleCn: '', shortName: 'SH' }, + { name: 'pablo', title: 'Pablo', titleCn: '', shortName: 'PB' }, ]; const strengthsData = [ - { name: '2mg', title: '2MG', titleCn: '2毫克' }, - { name: '4mg', title: '4MG', titleCn: '4毫克' }, - { name: '3mg', title: '3MG', titleCn: '3毫克' }, - { name: '6mg', title: '6MG', titleCn: '6毫克' }, - { name: '6.5mg', title: '6.5MG', titleCn: '6.5毫克' }, - { name: '9mg', title: '9MG', titleCn: '9毫克' }, - { name: '12mg', title: '12MG', titleCn: '12毫克' }, - { name: '16.5mg', title: '16.5MG', titleCn: '16.5毫克' }, - { name: '18mg', title: '18MG', titleCn: '18毫克' }, - { name: '30mg', title: '30MG', titleCn: '30毫克' }, + { name: '2mg', title: '2MG', titleCn: '2毫克', shortName: '2M' }, + { name: '3mg', title: '3MG', titleCn: '3毫克', shortName: '3M' }, + { name: '4mg', title: '4MG', titleCn: '4毫克', shortName: '4M' }, + { name: '6mg', title: '6MG', titleCn: '6毫克', shortName: '6M' }, + { name: '6.5mg', title: '6.5MG', titleCn: '6.5毫克', shortName: '6.5M' }, + { name: '9mg', title: '9MG', titleCn: '9毫克', shortName: '9M' }, + { name: '12mg', title: '12MG', titleCn: '12毫克', shortName: '12M' }, + { name: '16.5mg', title: '16.5MG', titleCn: '16.5毫克', shortName: '16.5M' }, + { name: '18mg', title: '18MG', titleCn: '18毫克', shortName: '18M' }, + { name: '30mg', title: '30MG', titleCn: '30毫克', shortName: '30M' }, ]; // 初始化语言字典 const locales = [ - { name: 'zh-cn', title: '简体中文', titleCn: '简体中文' }, - { name: 'en-us', title: 'English', titleCn: '英文' }, + { name: 'zh-cn', title: '简体中文', titleCn: '简体中文', shortName: 'CN' }, + { name: 'en-us', title: 'English', titleCn: '英文', shortName: 'EN' }, ]; for (const locale of locales) { @@ -114,19 +114,19 @@ export default class DictSeeder implements Seeder { // 添加中文翻译 let item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: zhDict.id } } }); if (!item) { - await dictItemRepository.save({ name: t.name, title: t.zh, titleCn: t.zh, dict: zhDict }); + await dictItemRepository.save({ name: t.name, title: t.zh, titleCn: t.zh, shortName: t.zh.substring(0, 2).toUpperCase(), dict: zhDict }); } // 添加英文翻译 item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: enDict.id } } }); if (!item) { - await dictItemRepository.save({ name: t.name, title: t.en, titleCn: t.en, dict: enDict }); + await dictItemRepository.save({ name: t.name, title: t.en, titleCn: t.en, shortName: t.en.substring(0, 2).toUpperCase(), dict: enDict }); } } - const brandDict = await this.createOrFindDict(dictRepository, { name: 'brand', title: '品牌', titleCn: '品牌' }); - const flavorDict = await this.createOrFindDict(dictRepository, { name: 'flavor', title: '口味', titleCn: '口味' }); - const strengthDict = await this.createOrFindDict(dictRepository, { name: 'strength', title: '强度', titleCn: '强度' }); + const brandDict = await this.createOrFindDict(dictRepository, { name: 'brand', title: '品牌', titleCn: '品牌', shortName: 'BR' }); + const flavorDict = await this.createOrFindDict(dictRepository, { name: 'flavor', title: '口味', titleCn: '口味', shortName: 'FL' }); + const strengthDict = await this.createOrFindDict(dictRepository, { name: 'strength', title: '强度', titleCn: '强度', shortName: 'ST' }); // 遍历品牌数据 await this.seedDictItems(dictItemRepository, brandDict, brandsData); @@ -144,13 +144,13 @@ export default class DictSeeder implements Seeder { * @param dictInfo 字典信息 * @returns Dict 实例 */ - private async createOrFindDict(repo: any, dictInfo: { name: string; title: string; titleCn: string }): Promise { + private async createOrFindDict(repo: any, dictInfo: { name: string; title: string; titleCn: string; shortName: string }): Promise { // 格式化 name const formattedName = this.formatName(dictInfo.name); let dict = await repo.findOne({ where: { name: formattedName } }); if (!dict) { // 如果字典不存在,则使用格式化后的 name 创建新字典 - dict = await repo.save({ name: formattedName, title: dictInfo.title, titleCn: dictInfo.titleCn }); + dict = await repo.save({ name: formattedName, title: dictInfo.title, titleCn: dictInfo.titleCn, shortName: dictInfo.shortName }); } return dict; } @@ -161,14 +161,14 @@ export default class DictSeeder implements Seeder { * @param dict 字典实例 * @param items 字典项数组 */ - private async seedDictItems(repo: any, dict: Dict, items: { name: string; title: string; titleCn: string }[]): Promise { + private async seedDictItems(repo: any, dict: Dict, items: { name: string; title: string; titleCn: string; shortName: string }[]): Promise { for (const item of items) { // 格式化 name const formattedName = this.formatName(item.name); const existingItem = await repo.findOne({ where: { name: formattedName, dict: { id: dict.id } } }); if (!existingItem) { // 如果字典项不存在,则使用格式化后的 name 创建新字典项 - await repo.save({ name: formattedName, title: item.title, titleCn: item.titleCn, dict }); + await repo.save({ name: formattedName, title: item.title, titleCn: item.titleCn, shortName: item.shortName, dict }); } } } diff --git a/src/db/seeds/template.seeder.ts b/src/db/seeds/template.seeder.ts index 1f8bed0..be51218 100644 --- a/src/db/seeds/template.seeder.ts +++ b/src/db/seeds/template.seeder.ts @@ -25,11 +25,24 @@ export default class TemplateSeeder implements Seeder { name: 'product.sku', value: '<%= it.brand %>-<%=it.category%>-<%= it.flavor %>-<%= it.strength %>-<%= it.humidity %>', description: '产品SKU模板', + testData: JSON.stringify({ + brand: 'Brand', + category: 'Category', + flavor: 'Flavor', + strength: '10mg', + humidity: 'Dry', + }), }, { name: 'product.title', value: '<%= it.brand %> <%= it.flavor %> <%= it.strength %> <%= it.humidity %>', description: '产品标题模板', + testData: JSON.stringify({ + brand: 'Brand', + flavor: 'Flavor', + strength: '10mg', + humidity: 'Dry', + }), }, ]; @@ -43,6 +56,7 @@ export default class TemplateSeeder implements Seeder { // 如果存在,则更新 existingTemplate.value = t.value; existingTemplate.description = t.description; + existingTemplate.testData = t.testData; await templateRepository.save(existingTemplate); } else { // 如果不存在,则创建并保存 @@ -50,6 +64,7 @@ export default class TemplateSeeder implements Seeder { template.name = t.name; template.value = t.value; template.description = t.description; + template.testData = t.testData; await templateRepository.save(template); } } diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index 167641f..07a8df2 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -228,6 +228,15 @@ export class BatchUpdateProductDTO { type?: string; } +/** + * DTO 用于批量删除产品 + */ +export class BatchDeleteProductDTO { + @ApiProperty({ description: '产品ID列表', type: 'array', required: true }) + @Rule(RuleType.array().items(RuleType.number()).required().min(1)) + ids: number[]; +} + /** * DTO 用于创建分类属性绑定 */ diff --git a/src/dto/site-api.dto.ts b/src/dto/site-api.dto.ts new file mode 100644 index 0000000..8fc1144 --- /dev/null +++ b/src/dto/site-api.dto.ts @@ -0,0 +1,258 @@ +import { ApiProperty } from '@midwayjs/swagger'; + +export class UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据' }) + items: T[]; + + @ApiProperty({ description: '总数', example: 100 }) + total: number; + + @ApiProperty({ description: '当前页', example: 1 }) + page: number; + + @ApiProperty({ description: '每页数量', example: 20 }) + per_page: number; + + @ApiProperty({ description: '总页数', example: 5 }) + totalPages: number; +} + +export class UnifiedImageDTO { + @ApiProperty({ description: '图片ID' }) + id: number | string; + + @ApiProperty({ description: '图片URL' }) + src: string; + + @ApiProperty({ description: '图片名称' }) + name?: string; + + @ApiProperty({ description: '替代文本' }) + alt?: string; +} + +export class UnifiedProductDTO { + @ApiProperty({ description: '产品ID' }) + id: string | number; + + @ApiProperty({ description: '产品名称' }) + name: string; + + @ApiProperty({ description: '产品类型' }) + type: string; + + @ApiProperty({ description: '产品状态' }) + status: string; + + @ApiProperty({ description: '产品SKU' }) + sku: string; + + @ApiProperty({ description: '常规价格' }) + regular_price: string; + + @ApiProperty({ description: '销售价格' }) + sale_price: string; + + @ApiProperty({ description: '当前价格' }) + price: string; + + @ApiProperty({ description: '库存状态' }) + stock_status: string; + + @ApiProperty({ description: '库存数量' }) + stock_quantity: number; + + @ApiProperty({ description: '产品图片', type: [UnifiedImageDTO] }) + images: UnifiedImageDTO[]; + + @ApiProperty({ description: '产品标签', type: 'json' }) + tags?: string[]; + + @ApiProperty({ description: '产品属性', type: 'json' }) + attributes: any[]; + + @ApiProperty({ description: '产品变体', type: 'json' }) + variations?: any[]; + + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '更新时间' }) + date_modified: string; + + @ApiProperty({ description: '原始数据(保留备用)', type: 'json' }) + raw?: any; +} + +export class UnifiedOrderDTO { + @ApiProperty({ description: '订单ID' }) + id: string | number; + + @ApiProperty({ description: '订单号' }) + number: string; + + @ApiProperty({ description: '订单状态' }) + status: string; + + @ApiProperty({ description: '货币' }) + currency: string; + + @ApiProperty({ description: '总金额' }) + total: string; + + @ApiProperty({ description: '客户ID' }) + customer_id: number; + + @ApiProperty({ description: '客户姓名' }) + customer_name: string; + + @ApiProperty({ description: '客户邮箱' }) + email: string; + + @ApiProperty({ description: '订单项', type: 'json' }) + line_items: any[]; + + @ApiProperty({ description: '销售项(兼容前端)', type: 'json' }) + sales?: any[]; + + @ApiProperty({ description: '账单地址', type: 'json' }) + billing: any; + + @ApiProperty({ description: '收货地址', type: 'json' }) + shipping: any; + + @ApiProperty({ description: '支付方式' }) + payment_method: string; + + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '原始数据', type: 'json' }) + raw?: any; +} + +export class UnifiedCustomerDTO { + @ApiProperty({ description: '客户ID' }) + id: string | number; + + @ApiProperty({ description: '邮箱' }) + email: string; + + @ApiProperty({ description: '名' }) + first_name?: string; + + @ApiProperty({ description: '姓' }) + last_name?: string; + + @ApiProperty({ description: '名字' }) + fullname?: string; + + @ApiProperty({ description: '用户名' }) + username?: string; + + @ApiProperty({ description: '电话' }) + phone?: string; + + @ApiProperty({ description: '账单地址', type: 'json' }) + billing?: any; + + @ApiProperty({ description: '收货地址', type: 'json' }) + shipping?: any; + + @ApiProperty({ description: '原始数据', type: 'json' }) + raw?: any; +} + +export class UnifiedSubscriptionDTO { + @ApiProperty({ description: '订阅ID' }) + id: string | number; + + @ApiProperty({ description: '订阅状态' }) + status: string; + + @ApiProperty({ description: '客户ID' }) + customer_id: number; + + @ApiProperty({ description: '计费周期' }) + billing_period: string; + + @ApiProperty({ description: '计费间隔' }) + billing_interval: number; + + @ApiProperty({ description: '开始时间' }) + start_date: string; + + @ApiProperty({ description: '下次支付时间' }) + next_payment_date: string; + + @ApiProperty({ description: '订单项', type: 'json' }) + line_items: any[]; + + @ApiProperty({ description: '原始数据', type: 'json' }) + raw?: any; +} + +export class UnifiedMediaDTO { + @ApiProperty({ description: '媒体ID' }) + id: number; + + @ApiProperty({ description: '标题' }) + title: string; + + @ApiProperty({ description: '媒体类型' }) + media_type: string; + + @ApiProperty({ description: 'MIME类型' }) + mime_type: string; + + @ApiProperty({ description: '源URL' }) + source_url: string; + + @ApiProperty({ description: '创建时间' }) + date_created: string; +} + +export class UnifiedProductPaginationDTO extends UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据', type: [UnifiedProductDTO] }) + items: UnifiedProductDTO[]; +} + +export class UnifiedOrderPaginationDTO extends UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据', type: [UnifiedOrderDTO] }) + items: UnifiedOrderDTO[]; +} + +export class UnifiedCustomerPaginationDTO extends UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据', type: [UnifiedCustomerDTO] }) + items: UnifiedCustomerDTO[]; +} + +export class UnifiedSubscriptionPaginationDTO extends UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据', type: [UnifiedSubscriptionDTO] }) + items: UnifiedSubscriptionDTO[]; +} + +export class UnifiedMediaPaginationDTO extends UnifiedPaginationDTO { + @ApiProperty({ description: '列表数据', type: [UnifiedMediaDTO] }) + items: UnifiedMediaDTO[]; +} + +export class UnifiedSearchParamsDTO { + @ApiProperty({ description: '页码', example: 1 }) + page?: number; + + @ApiProperty({ description: '每页数量', example: 20 }) + per_page?: number; + + @ApiProperty({ description: '搜索关键词' }) + search?: string; + + @ApiProperty({ description: '状态' }) + status?: string; + + @ApiProperty({ description: '排序字段' }) + orderby?: string; + + @ApiProperty({ description: '排序方式' }) + order?: string; +} diff --git a/src/dto/site.dto.ts b/src/dto/site.dto.ts index 103c9ad..bf76f40 100644 --- a/src/dto/site.dto.ts +++ b/src/dto/site.dto.ts @@ -22,6 +22,10 @@ export class SiteConfig { @Rule(RuleType.string()) name: string; + @ApiProperty({ description: '描述' }) + @Rule(RuleType.string().allow('').optional()) + description?: string; + @ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'] }) @Rule(RuleType.string().valid('woocommerce', 'shopyy')) type: string; @@ -42,6 +46,8 @@ export class CreateSiteDTO { token?: string; @Rule(RuleType.string()) name: string; + @Rule(RuleType.string().allow('').optional()) + description?: string; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) type?: string; @Rule(RuleType.string().optional()) @@ -51,6 +57,11 @@ export class CreateSiteDTO { @ApiProperty({ description: '区域' }) @Rule(RuleType.array().items(RuleType.string()).optional()) areas?: string[]; + + // 绑定仓库 + @ApiProperty({ description: '绑定仓库ID列表' }) + @Rule(RuleType.array().items(RuleType.number()).optional()) + stockPointIds?: number[]; } export class UpdateSiteDTO { @@ -64,6 +75,8 @@ export class UpdateSiteDTO { token?: string; @Rule(RuleType.string().optional()) name?: string; + @Rule(RuleType.string().allow('').optional()) + description?: string; @Rule(RuleType.boolean().optional()) isDisabled?: boolean; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) @@ -75,6 +88,11 @@ export class UpdateSiteDTO { @ApiProperty({ description: '区域' }) @Rule(RuleType.array().items(RuleType.string()).optional()) areas?: string[]; + + // 绑定仓库 + @ApiProperty({ description: '绑定仓库ID列表' }) + @Rule(RuleType.array().items(RuleType.number()).optional()) + stockPointIds?: number[]; } export class QuerySiteDTO { diff --git a/src/dto/stock.dto.ts b/src/dto/stock.dto.ts index aa5a013..54629a0 100644 --- a/src/dto/stock.dto.ts +++ b/src/dto/stock.dto.ts @@ -172,6 +172,14 @@ export class CreateStockPointDTO { @ApiProperty({ description: '区域' }) @Rule(RuleType.array().items(RuleType.string()).optional()) areas?: string[]; + + @ApiProperty({ description: '上游仓库点ID' }) + @Rule(RuleType.number().optional()) + upStreamStockPointId?: number; + + @ApiProperty({ description: '上游名称' }) + @Rule(RuleType.string().optional()) + upStreamName?: string; } export class UpdateStockPointDTO extends CreateStockPointDTO {} diff --git a/src/dto/template.dto.ts b/src/dto/template.dto.ts index e74296c..cd0abd3 100644 --- a/src/dto/template.dto.ts +++ b/src/dto/template.dto.ts @@ -9,6 +9,10 @@ export class CreateTemplateDTO { @ApiProperty({ description: '模板内容', required: true }) @Rule(RuleType.string().required()) value: string; + + @ApiProperty({ description: '测试数据JSON', required: false }) + @Rule(RuleType.string().optional()) + testData?: string; } export class UpdateTemplateDTO { @@ -19,4 +23,8 @@ export class UpdateTemplateDTO { @ApiProperty({ description: '模板内容', required: true }) @Rule(RuleType.string().required()) value: string; + + @ApiProperty({ description: '测试数据JSON', required: false }) + @Rule(RuleType.string().optional()) + testData?: string; } diff --git a/src/dto/wp_product.dto.ts b/src/dto/wp_product.dto.ts index 11e2a7b..48212a3 100644 --- a/src/dto/wp_product.dto.ts +++ b/src/dto/wp_product.dto.ts @@ -13,46 +13,58 @@ export class WpProductDTO extends WpProduct { export class UpdateVariationDTO { @ApiProperty({ description: '产品名称' }) - @Rule(RuleType.string()) - name: string; + @Rule(RuleType.string().optional()) + name?: string; @ApiProperty({ description: 'SKU' }) - @Rule(RuleType.string().allow('')) - sku: string; + @Rule(RuleType.string().allow('').optional()) + sku?: string; @ApiProperty({ description: '常规价格', type: Number }) - @Rule(RuleType.number()) - regular_price: number; // 常规价格 + @Rule(RuleType.number().optional()) + regular_price?: number; // 常规价格 @ApiProperty({ description: '销售价格', type: Number }) - @Rule(RuleType.number()) - sale_price: number; // 销售价格 + @Rule(RuleType.number().optional()) + sale_price?: number; // 销售价格 @ApiProperty({ description: '是否促销中', type: Boolean }) - @Rule(RuleType.boolean()) - on_sale: boolean; // 是否促销中 + @Rule(RuleType.boolean().optional()) + on_sale?: boolean; // 是否促销中 } export class UpdateWpProductDTO { @ApiProperty({ description: '变体名称' }) - @Rule(RuleType.string()) - name: string; + @Rule(RuleType.string().optional()) + name?: string; @ApiProperty({ description: 'SKU' }) - @Rule(RuleType.string().allow('')) - sku: string; + @Rule(RuleType.string().allow('').optional()) + sku?: string; @ApiProperty({ description: '常规价格', type: Number }) - @Rule(RuleType.number()) - regular_price: number; // 常规价格 + @Rule(RuleType.number().optional()) + regular_price?: number; // 常规价格 @ApiProperty({ description: '销售价格', type: Number }) - @Rule(RuleType.number()) - sale_price: number; // 销售价格 + @Rule(RuleType.number().optional()) + sale_price?: number; // 销售价格 @ApiProperty({ description: '是否促销中', type: Boolean }) - @Rule(RuleType.boolean()) - on_sale: boolean; // 是否促销中 + @Rule(RuleType.boolean().optional()) + on_sale?: boolean; // 是否促销中 + + @ApiProperty({ description: '分类列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + categories?: string[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + tags?: string[]; + + @ApiProperty({ description: '站点ID', required: false }) + @Rule(RuleType.number().optional()) + siteId?: number; } export class QueryWpProductDTO { @@ -75,24 +87,50 @@ export class QueryWpProductDTO { @ApiProperty({ description: '产品状态', enum: ProductStatus }) @Rule(RuleType.string().valid(...Object.values(ProductStatus))) status?: ProductStatus; + + @ApiProperty({ description: 'SKU列表', type: Array }) + @Rule(RuleType.array().items(RuleType.string()).single()) + skus?: string[]; } -export class SetConstitutionDTO { - @ApiProperty({ type: Boolean }) - @Rule(RuleType.boolean()) - isProduct: boolean; - - @ApiProperty({ - description: '构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Rule(RuleType.array()) - constitution: { sku: string; quantity: number }[] | null; +export class BatchSyncProductsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + productIds: number[]; +} + +export class BatchUpdateTagsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + ids: number[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).required()) + tags: string[]; +} + +export class BatchUpdateProductsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + ids: number[]; + + @ApiProperty({ description: '常规价格', type: Number }) + @Rule(RuleType.number()) + regular_price?: number; + + @ApiProperty({ description: '销售价格', type: Number }) + @Rule(RuleType.number()) + sale_price?: number; + + @ApiProperty({ description: '分类列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string())) + categories?: string[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string())) + tags?: string[]; + + @ApiProperty({ description: '状态', enum: ProductStatus }) + @Rule(RuleType.string().valid(...Object.values(ProductStatus))) + status?: ProductStatus; } diff --git a/src/entity/site.entity.ts b/src/entity/site.entity.ts index cdf70d9..12be041 100644 --- a/src/entity/site.entity.ts +++ b/src/entity/site.entity.ts @@ -1,5 +1,6 @@ import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Area } from './area.entity'; +import { StockPoint } from './stock_point.entity'; @Entity('site') export class Site { @@ -10,17 +11,20 @@ export class Site { apiUrl: string; @Column({ length: 255, nullable: true }) - consumerKey: string; + consumerKey?: string; @Column({ length: 255, nullable: true }) - consumerSecret: string; + consumerSecret?: string; @Column({ nullable: true }) - token: string; + token?: string; @Column({ length: 255, unique: true }) name: string; + @Column({ length: 255, nullable: true }) + description?: string; + @Column({ length: 32, default: 'woocommerce' }) type: string; // 平台类型:woocommerce | shopyy @@ -33,4 +37,8 @@ export class Site { @ManyToMany(() => Area) @JoinTable() areas: Area[]; + + @ManyToMany(() => StockPoint, stockPoint => stockPoint.sites) + @JoinTable() + stockPoints: StockPoint[]; } \ No newline at end of file diff --git a/src/entity/stock_point.entity.ts b/src/entity/stock_point.entity.ts index fd62318..dda7ea8 100644 --- a/src/entity/stock_point.entity.ts +++ b/src/entity/stock_point.entity.ts @@ -13,6 +13,7 @@ import { } from 'typeorm'; import { Shipment } from './shipment.entity'; import { Area } from './area.entity'; +import { Site } from './site.entity'; @Entity('stock_point') export class StockPoint extends BaseEntity { @@ -54,7 +55,7 @@ export class StockPoint extends BaseEntity { @Column({ default: 'uniuni' }) upStreamName: string; - @Column() + @Column({ default: 0 }) upStreamStockPointId: number; @ApiProperty({ @@ -79,4 +80,7 @@ export class StockPoint extends BaseEntity { @ManyToMany(() => Area) @JoinTable() areas: Area[]; + + @ManyToMany(() => Site, site => site.stockPoints) + sites: Site[]; } diff --git a/src/entity/template.entity.ts b/src/entity/template.entity.ts index 81c64f7..13fee49 100644 --- a/src/entity/template.entity.ts +++ b/src/entity/template.entity.ts @@ -25,6 +25,10 @@ export class Template { @Column('text',{nullable: true,comment: "描述"}) description?: string; + @ApiProperty({ type: 'string', nullable: true, description: '测试数据JSON' }) + @Column('text', { nullable: true, comment: '测试数据JSON' }) + testData?: string; + @ApiProperty({ example: true, description: '是否可删除', diff --git a/src/entity/variation.entity.ts b/src/entity/variation.entity.ts index 9dcdea2..b3232f5 100644 --- a/src/entity/variation.entity.ts +++ b/src/entity/variation.entity.ts @@ -101,18 +101,4 @@ export class Variation { }) @UpdateDateColumn() updatedAt: Date; - - @ApiProperty({ - description: '变体构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Column('json', { nullable: true, comment: '变体构成成分' }) - constitution: { sku: string; quantity: number }[] | null; } diff --git a/src/entity/wp_product.entity.ts b/src/entity/wp_product.entity.ts index 939e13a..b0327db 100644 --- a/src/entity/wp_product.entity.ts +++ b/src/entity/wp_product.entity.ts @@ -33,6 +33,7 @@ export class WpProduct { @Column({ type: 'int', nullable: true }) siteId: number; + @ApiProperty({ description: '站点信息', type: Site }) @ManyToOne(() => Site) @JoinColumn({ name: 'siteId', referencedColumnName: 'id' }) site: Site; @@ -223,18 +224,4 @@ export class WpProduct { }) @UpdateDateColumn() updatedAt: Date; - - @ApiProperty({ - description: '产品构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Column('json', { nullable: true, comment: '产品构成成分' }) - constitution: { sku: string; quantity: number }[] | null; } diff --git a/src/interface/platform.interface.ts b/src/interface/platform.interface.ts new file mode 100644 index 0000000..07ddd55 --- /dev/null +++ b/src/interface/platform.interface.ts @@ -0,0 +1,125 @@ +// src/interface/platform.interface.ts + +/** + * 电商平台抽象接口 + * 定义所有平台必须实现的通用方法 + */ +export interface IPlatformService { + /** + * 获取产品列表 + * @param site 站点配置信息 + * @returns 产品列表数据 + */ + getProducts(site: any): Promise; + + /** + * 获取产品变体列表 + * @param site 站点配置信息 + * @param productId 产品ID + * @returns 变体列表数据 + */ + getVariations(site: any, productId: number): Promise; + + /** + * 获取产品变体详情 + * @param site 站点配置信息 + * @param productId 产品ID + * @param variationId 变体ID + * @returns 变体详情数据 + */ + getVariation(site: any, productId: number, variationId: number): Promise; + + /** + * 获取订单列表 + * @param siteId 站点ID + * @returns 订单列表数据 + */ + getOrders(siteId: number): Promise; + + /** + * 获取订单详情 + * @param siteId 站点ID + * @param orderId 订单ID + * @returns 订单详情数据 + */ + getOrder(siteId: string, orderId: string): Promise; + + /** + * 获取订阅列表(如果平台支持) + * @param siteId 站点ID + * @returns 订阅列表数据 + */ + getSubscriptions?(siteId: number): Promise; + + /** + * 创建产品 + * @param site 站点配置信息 + * @param data 产品数据 + * @returns 创建结果 + */ + createProduct(site: any, data: any): Promise; + + /** + * 更新产品 + * @param site 站点配置信息 + * @param productId 产品ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateProduct(site: any, productId: string, data: any): Promise; + + /** + * 更新产品状态 + * @param site 站点配置信息 + * @param productId 产品ID + * @param status 产品状态 + * @param stockStatus 库存状态 + * @returns 更新结果 + */ + updateProductStatus(site: any, productId: string, status: string, stockStatus: string): Promise; + + /** + * 更新产品变体 + * @param site 站点配置信息 + * @param productId 产品ID + * @param variationId 变体ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateVariation(site: any, productId: string, variationId: string, data: any): Promise; + + /** + * 更新订单 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateOrder(site: any, orderId: string, data: Record): Promise; + + /** + * 创建物流信息 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param data 物流数据 + * @returns 创建结果 + */ + createShipment(site: any, orderId: string, data: any): Promise; + + /** + * 删除物流信息 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param trackingId 物流跟踪ID + * @returns 删除结果 + */ + deleteShipment(site: any, orderId: string, trackingId: string): Promise; + + /** + * 批量处理产品 + * @param site 站点配置信息 + * @param data 批量操作数据 + * @returns 处理结果 + */ + batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise; +} diff --git a/src/interface/site-adapter.interface.ts b/src/interface/site-adapter.interface.ts new file mode 100644 index 0000000..9ace1cc --- /dev/null +++ b/src/interface/site-adapter.interface.ts @@ -0,0 +1,81 @@ +import { + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedCustomerDTO, +} from '../dto/site-api.dto'; + +export interface ISiteAdapter { + /** + * 获取产品列表 + */ + getProducts(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取单个产品 + */ + getProduct(id: string | number): Promise; + + /** + * 获取订单列表 + */ + getOrders(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取单个订单 + */ + getOrder(id: string | number): Promise; + + /** + * 获取订阅列表 + */ + getSubscriptions(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取媒体列表 + */ + getMedia(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 创建产品 + */ + createProduct(data: Partial): Promise; + + /** + * 更新产品 + */ + updateProduct(id: string | number, data: Partial): Promise; + + /** + * 更新产品变体 + */ + updateVariation(productId: string | number, variationId: string | number, data: any): Promise; + + /** + * 获取订单备注 + */ + getOrderNotes(orderId: string | number): Promise; + + /** + * 创建订单备注 + */ + createOrderNote(orderId: string | number, data: any): Promise; + + /** + * 删除产品 + */ + deleteProduct(id: string | number): Promise; + + createOrder(data: Partial): Promise; + updateOrder(id: string | number, data: Partial): Promise; + deleteOrder(id: string | number): Promise; + + getCustomers(params: UnifiedSearchParamsDTO): Promise>; + getCustomer(id: string | number): Promise; + createCustomer(data: Partial): Promise; + updateCustomer(id: string | number, data: Partial): Promise; + deleteCustomer(id: string | number): Promise; +} diff --git a/src/service/order.service.ts b/src/service/order.service.ts index 3020542..aac76d0 100644 --- a/src/service/order.service.ts +++ b/src/service/order.service.ts @@ -104,15 +104,33 @@ export class OrderService { async syncOrders(siteId: number) { // 调用 WooCommerce API 获取订单 const orders = await this.wpService.getOrders(siteId); + let successCount = 0; + let failureCount = 0; for (const order of orders) { - await this.syncSingleOrder(siteId, order); + try { + await this.syncSingleOrder(siteId, order); + successCount++; + } catch (error) { + console.error(`同步订单 ${order.id} 失败:`, error); + failureCount++; + } } + return { + success: failureCount === 0, + message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, + }; } async syncOrderById(siteId: number, orderId: string) { - // 调用 WooCommerce API 获取订单 - const order = await this.wpService.getOrder(String(siteId), orderId); - await this.syncSingleOrder(siteId, order, true); + try { + // 调用 WooCommerce API 获取订单 + const order = await this.wpService.getOrder(String(siteId), orderId); + await this.syncSingleOrder(siteId, order, true); + return { success: true, message: '同步成功' }; + } catch (error) { + console.error(`同步订单 ${orderId} 失败:`, error); + return { success: false, message: `同步失败: ${error.message}` }; + } } // 订单状态切换表 orderAutoNextStatusMap = { @@ -397,39 +415,51 @@ export class OrderService { await this.orderSaleModel.delete(currentOrderSale.map(v => v.id)); } if (!orderItem.sku) return; - let constitution; - if (orderItem.externalVariationId === '0') { - const product = await this.wpProductModel.findOne({ - where: { sku: orderItem.sku }, - }); - if (!product) return; - constitution = product?.constitution; - } else { - const variation = await this.variationModel.findOne({ - where: { sku: orderItem.sku }, - }); - if (!variation) return; - constitution = variation?.constitution; - } - if (!Array.isArray(constitution)) return; + const product = await this.productModel.findOne({ + where: { sku: orderItem.sku }, + relations: ['components'], + }); + + if (!product) return; + const orderSales: OrderSale[] = []; - for (const item of constitution) { - const baseProduct = await this.productModel.findOne({ - where: { sku: item.sku }, - }); + + if (product.components && product.components.length > 0) { + for (const comp of product.components) { + const baseProduct = await this.productModel.findOne({ + where: { sku: comp.sku }, + }); + if (baseProduct) { + const orderSaleItem: OrderSale = plainToClass(OrderSale, { + orderId: orderItem.orderId, + siteId: orderItem.siteId, + externalOrderItemId: orderItem.externalOrderItemId, + productId: baseProduct.id, + name: baseProduct.name, + quantity: comp.quantity * orderItem.quantity, + sku: comp.sku, + isPackage: orderItem.name.toLowerCase().includes('package'), + }); + orderSales.push(orderSaleItem); + } + } + } else { const orderSaleItem: OrderSale = plainToClass(OrderSale, { orderId: orderItem.orderId, siteId: orderItem.siteId, externalOrderItemId: orderItem.externalOrderItemId, - productId: baseProduct.id, - name: baseProduct.name, - quantity: item.quantity * orderItem.quantity, - sku: item.sku, + productId: product.id, + name: product.name, + quantity: orderItem.quantity, + sku: product.sku, isPackage: orderItem.name.toLowerCase().includes('package'), }); orderSales.push(orderSaleItem); } - await this.orderSaleModel.save(orderSales); + + if (orderSales.length > 0) { + await this.orderSaleModel.save(orderSales); + } } async saveOrderRefunds({ diff --git a/src/service/platform.factory.ts b/src/service/platform.factory.ts new file mode 100644 index 0000000..82dd90e --- /dev/null +++ b/src/service/platform.factory.ts @@ -0,0 +1,41 @@ +// src/service/platform.factory.ts + +import { Provide, Scope, ScopeEnum, Inject } from '@midwayjs/core'; +import { Site } from '../entity/site.entity'; +import { IPlatformService } from '../interface/platform.interface'; +import { WPService } from './wp.service'; +import { ShopyyService } from './shopyy.service'; + +/** + * 平台服务工厂 + * 根据站点类型创建对应的平台服务实例 + */ +@Provide() +@Scope(ScopeEnum.Singleton) +export class PlatformFactory { + @Inject() + wpService: WPService; + + @Inject() + shopyyService: ShopyyService; + + /** + * 根据站点类型创建对应的平台服务实例 + * @param site 站点配置信息 + * @returns 平台服务实例 + */ + createPlatformService(site: Site): IPlatformService { + switch (site.type) { + case 'woocommerce': + return this.wpService; + case 'shopyy': + return this.shopyyService; + case 'amazon': + // 这里需要引入并返回AmazonService实例 + // 目前先返回WPService作为占位 + return this.wpService; + default: + throw new Error(`不支持的平台类型: ${site.type}`); + } + } +} diff --git a/src/service/product.service.ts b/src/service/product.service.ts index d1733ee..e54ee29 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -371,7 +371,10 @@ export class ProductService { // 如果提供了 categoryId,设置分类 if (categoryId) { - categoryItem = await this.categoryModel.findOne({ where: { id: categoryId } }); + categoryItem = await this.categoryModel.findOne({ + where: { id: categoryId }, + relations: ['attributes', 'attributes.attributeDict'] + }); if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`); } @@ -379,16 +382,23 @@ export class ProductService { // 如果属性是分类,特殊处理 if (attr.dictName === 'category') { if (attr.id) { - categoryItem = await this.categoryModel.findOneBy({ id: attr.id }); + categoryItem = await this.categoryModel.findOne({ + where: { id: attr.id }, + relations: ['attributes', 'attributes.attributeDict'] + }); } else if (attr.name) { - categoryItem = await this.categoryModel.findOneBy({ name: attr.name }); + categoryItem = await this.categoryModel.findOne({ + where: { name: attr.name }, + relations: ['attributes', 'attributes.attributeDict'] + }); } else if (attr.title) { // 尝试用 title 匹配 name 或 title categoryItem = await this.categoryModel.findOne({ where: [ { name: attr.title }, { title: attr.title } - ] + ], + relations: ['attributes', 'attributes.attributeDict'] }); } continue; @@ -440,16 +450,7 @@ export class ProductService { if (sku) { product.sku = sku; } else { - const attributeMap: Record = {}; - for (const a of resolvedAttributes) { - if (a?.dict?.name && a?.name) attributeMap[a.dict.name] = a.name; - } - product.sku = await this.templateService.render('product.sku', { - brand: attributeMap['brand'] || '', - flavor: attributeMap['flavor'] || '', - strength: attributeMap['strength'] || '', - humidity: attributeMap['humidity'] || '', - }); + product.sku = await this.templateService.render('product.sku', product); } const savedProduct = await this.productModel.save(product); @@ -632,6 +633,28 @@ export class ProductService { return true; } + async batchDeleteProduct(ids: number[]): Promise<{ success: number; failed: number; errors: string[] }> { + if (!ids || ids.length === 0) { + throw new Error('未选择任何产品'); + } + + let success = 0; + let failed = 0; + const errors: string[] = []; + + for (const id of ids) { + try { + await this.deleteProduct(id); + success++; + } catch (error) { + failed++; + errors.push(`ID ${id}: ${error.message}`); + } + } + + return { success, failed, errors }; + } + // 获取产品的库存组成列表(表关联版本) async getProductComponents(productId: number): Promise { // 条件判断:确保产品存在 @@ -780,25 +803,14 @@ export class ProductService { if (!product) { throw new Error(`产品 ID ${id} 不存在`); } - const sku = product.sku; // 查询 wp_product 表中是否存在与该 SKU 关联的产品 - const wpProduct = await this.wpProductModel - .createQueryBuilder('wp_product') - .where('JSON_CONTAINS(wp_product.constitution, :sku)', { - sku: JSON.stringify({ sku: sku }), - }) - .getOne(); + const wpProduct = await this.wpProductModel.findOne({ where: { sku: product.sku } }); if (wpProduct) { throw new Error('无法删除,请先删除关联的WP产品'); } - const variation = await this.variationModel - .createQueryBuilder('variation') - .where('JSON_CONTAINS(variation.constitution, :sku)', { - sku: JSON.stringify({ sku: sku }), - }) - .getOne(); + const variation = await this.variationModel.findOne({ where: { sku: product.sku } }); if (variation) { console.log(variation); @@ -1293,26 +1305,6 @@ export class ProductService { } } - // 解析组件信息 (component_*) - const componentsMap = new Map(); - for (const key of Object.keys(rec)) { - const skuMatch = key.match(/^component_(\d+)_sku$/); - if (skuMatch) { - const idx = skuMatch[1]; - if (!componentsMap.has(idx)) componentsMap.set(idx, {}); - componentsMap.get(idx)!.sku = rec[key]; - } - const qtyMatch = key.match(/^component_(\d+)_quantity$/); - if (qtyMatch) { - const idx = qtyMatch[1]; - if (!componentsMap.has(idx)) componentsMap.set(idx, {}); - componentsMap.get(idx)!.quantity = Number(rec[key]); - } - } - const components = Array.from(componentsMap.values()) - .filter(c => c.sku && c.quantity) - .map(c => ({ sku: c.sku!, quantity: c.quantity! })); - return { sku, name: val(rec.name), @@ -1324,7 +1316,6 @@ export class ProductService { siteSkus: rec.siteSkus ? String(rec.siteSkus).split(',').map(s => s.trim()).filter(Boolean) : undefined, attributes: attributes.length > 0 ? attributes : undefined, - components: components.length > 0 ? components : undefined, } as any; } @@ -1349,8 +1340,7 @@ export class ProductService { dto.attributes = Array.isArray(data.attributes) ? data.attributes : []; // 如果有组件信息,透传 - dto.type = data.type || data.components?.length ? 'bundle' : 'single' - if (data.components) dto.components = data.components; + dto.type = data.type || 'single'; return dto; } @@ -1574,4 +1564,38 @@ export class ProductService { return { created, updated, errors }; } + + // 将库存记录的 sku 添加到产品单品中 + async syncStockToProduct(): Promise<{ added: number; errors: string[] }> { + // 1. 获取所有库存记录的 SKU (去重) + const stockSkus = await this.stockModel + .createQueryBuilder('stock') + .select('DISTINCT(stock.sku)', 'sku') + .getRawMany(); + + const skus = stockSkus.map(s => s.sku).filter(Boolean); + let added = 0; + const errors: string[] = []; + + // 2. 遍历 SKU,检查并添加 + for (const sku of skus) { + try { + const exist = await this.productModel.findOne({ where: { sku } }); + if (!exist) { + const product = new Product(); + product.sku = sku; + product.name = sku; // 默认使用 SKU 作为名称 + product.type = 'single'; + product.price = 0; + product.promotionPrice = 0; + await this.productModel.save(product); + added++; + } + } catch (error) { + errors.push(`SKU ${sku} 添加失败: ${error.message}`); + } + } + + return { added, errors }; + } } diff --git a/src/service/shopyy.service.ts b/src/service/shopyy.service.ts new file mode 100644 index 0000000..ca6cd66 --- /dev/null +++ b/src/service/shopyy.service.ts @@ -0,0 +1,505 @@ +import { Inject, Provide } from '@midwayjs/core'; +import axios, { AxiosRequestConfig } from 'axios'; +import { IPlatformService } from '../interface/platform.interface'; +import { SiteService } from './site.service'; +import { Site } from '../entity/site.entity'; + +/** + * ShopYY平台服务实现 + */ +@Provide() +export class ShopyyService implements IPlatformService { + @Inject() + private readonly siteService: SiteService; + + /** + * 构建ShopYY API请求URL + * @param baseUrl 基础URL + * @param endpoint API端点 + * @returns 完整URL + */ + private buildURL(baseUrl: string, endpoint: string): string { + // ShopYY API URL格式:https://{shop}.shopyy.com/openapi/{version}/{endpoint} + const base = baseUrl.replace(/\/$/, ''); + const end = endpoint.replace(/^\//, ''); + return `${base}/${end}`; + } + + /** + * 构建ShopYY API请求头 + * @param site 站点配置 + * @returns 请求头 + */ + private buildHeaders(site: Site): Record { + if(!site?.token){ + throw new Error(`获取站点${site?.name}数据,但失败,因为未设置站点令牌配置`) + } + return { + 'Content-Type': 'application/json', + token: site.token || '' + }; + } + + /** + * 发送ShopYY API请求 + * @param site 站点配置 + * @param endpoint API端点 + * @param method 请求方法 + * @param data 请求数据 + * @param params 请求参数 + * @returns 响应数据 + */ + private async request(site: any, endpoint: string, method: string = 'GET', data: any = null, params: any = null): Promise { + const url = this.buildURL(site.apiUrl, endpoint); + const headers = this.buildHeaders(site); + + const config: AxiosRequestConfig = { + url, + method, + headers, + params, + data + }; + + try { + const response = await axios(config); + return response.data; + } catch (error) { + console.error('ShopYY API请求失败:', error.response?.data || error.message); + throw error; + } + } + + /** + * 通用分页获取资源 + */ + public async fetchResourcePaged(site: any, endpoint: string, params: Record = {}) { + // 映射 params 字段: page -> page, per_page -> limit + const requestParams = { + ...params, + page: params.page || 1, + limit: params.per_page || 20 + }; + const response = await this.request(site, endpoint, 'GET', null, requestParams); + if(response?.code !== 0){ + throw new Error(response?.msg) + } + return { + items: response.data.list || [], + total: response.data?.paginate?.total || 0, + totalPages: response.data?.paginate?.pageTotal || 0, + page: response.data?.paginate?.current || requestParams.page, + per_page: response.data?.paginate?.pagesize || requestParams.limit + }; + } + + /** + * 获取ShopYY产品列表 + * @param site 站点配置 + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页产品列表 + */ + async getProducts(site: any, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /products + const response = await this.request(site, 'products', 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取单个ShopYY产品 + * @param site 站点配置 + * @param productId 产品ID + * @returns 产品详情 + */ + async getProduct(site: any, productId: string | number): Promise { + // ShopYY API: GET /products/{id} + const response = await this.request(site, `products/${productId}`, 'GET'); + return response.data; + } + + /** + * 获取ShopYY产品变体列表 + * @param site 站点配置 + * @param productId 产品ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页变体列表 + */ + async getVariations(site: any, productId: number, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /products/{id}/variations + const response = await this.request(site, `products/${productId}/variations`, 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取ShopYY产品变体详情 + * @param site 站点配置 + * @param productId 产品ID + * @param variationId 变体ID + * @returns 变体详情 + */ + async getVariation(site: any, productId: number, variationId: number): Promise { + // ShopYY API: GET /products/{product_id}/variations/{variation_id} + const response = await this.request(site, `products/${productId}/variations/${variationId}`, 'GET'); + return response.data; + } + + /** + * 获取ShopYY订单列表 + * @param site 站点配置或站点ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页订单列表 + */ + async getOrders(site: any | number, page: number = 1, pageSize: number = 100): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; + + // ShopYY API: GET /orders + const response = await this.request(siteConfig, 'orders', 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取ShopYY订单详情 + * @param siteId 站点ID + * @param orderId 订单ID + * @returns 订单详情 + */ + async getOrder(siteId: string, orderId: string): Promise { + const site = await this.siteService.get(Number(siteId)); + + // ShopYY API: GET /orders/{id} + const response = await this.request(site, `orders/${orderId}`, 'GET'); + return response.data; + } + + /** + * 创建ShopYY产品 + * @param site 站点配置 + * @param data 产品数据 + * @returns 创建结果 + */ + async createProduct(site: any, data: any): Promise { + // ShopYY API: POST /products + const response = await this.request(site, 'products', 'POST', data); + return response.data; + } + + /** + * 更新ShopYY产品 + * @param site 站点配置 + * @param productId 产品ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateProduct(site: any, productId: string, data: any): Promise { + try { + // ShopYY API: PUT /products/{id} + await this.request(site, `products/${productId}`, 'PUT', data); + return true; + } catch (error) { + console.error('更新ShopYY产品失败:', error); + return false; + } + } + + /** + * 更新ShopYY产品状态 + * @param site 站点配置 + * @param productId 产品ID + * @param status 产品状态 + * @param stockStatus 库存状态 + * @returns 更新结果 + */ + async updateProductStatus(site: any, productId: string, status: string, stockStatus: string): Promise { + // ShopYY产品状态映射 + const shopyyStatus = status === 'publish' ? 1 : 0; + const shopyyStockStatus = stockStatus === 'instock' ? 1 : 0; + + try { + await this.request(site, `products/${productId}`, 'PUT', { + status: shopyyStatus, + stock_status: shopyyStockStatus + }); + return true; + } catch (error) { + console.error('更新ShopYY产品状态失败:', error); + return false; + } + } + + /** + * 更新ShopYY产品变体 + * @param site 站点配置 + * @param productId 产品ID + * @param variationId 变体ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateVariation(site: any, productId: string, variationId: string, data: any): Promise { + try { + // ShopYY API: PUT /products/{product_id}/variations/{variation_id} + await this.request(site, `products/${productId}/variations/${variationId}`, 'PUT', data); + return true; + } catch (error) { + console.error('更新ShopYY产品变体失败:', error); + return false; + } + } + + /** + * 更新ShopYY订单 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateOrder(site: any, orderId: string, data: Record): Promise { + try { + // ShopYY API: PUT /orders/{id} + await this.request(site, `orders/${orderId}`, 'PUT', data); + return true; + } catch (error) { + console.error('更新ShopYY订单失败:', error); + return false; + } + } + + /** + * 创建ShopYY物流信息 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 物流数据 + * @returns 创建结果 + */ + async createShipment(site: any, orderId: string, data: any): Promise { + // ShopYY API: POST /orders/{id}/shipments + const shipmentData = { + tracking_number: data.tracking_number, + carrier_code: data.carrier_code, + carrier_name: data.carrier_name, + shipping_method: data.shipping_method + }; + + const response = await this.request(site, `orders/${orderId}/shipments`, 'POST', shipmentData); + return response.data; + } + + /** + * 删除ShopYY物流信息 + * @param site 站点配置 + * @param orderId 订单ID + * @param trackingId 物流跟踪ID + * @returns 删除结果 + */ + async deleteShipment(site: any, orderId: string, trackingId: string): Promise { + try { + // ShopYY API: DELETE /orders/{order_id}/shipments/{tracking_id} + await this.request(site, `orders/${orderId}/shipments/${trackingId}`, 'DELETE'); + return true; + } catch (error) { + console.error('删除ShopYY物流信息失败:', error); + return false; + } + } + + /** + * 获取ShopYY订单备注 + * @param site 站点配置 + * @param orderId 订单ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页订单备注列表 + */ + async getOrderNotes(site: any, orderId: string | number, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /orders/{id}/notes + const response = await this.request(site, `orders/${orderId}/notes`, 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 创建ShopYY订单备注 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 备注数据 + * @returns 创建结果 + */ + async createOrderNote(site: any, orderId: string | number, data: any): Promise { + // ShopYY API: POST /orders/{id}/notes + const noteData = { + note: data.note, + is_customer_note: data.is_customer_note || false + }; + const response = await this.request(site, `orders/${orderId}/notes`, 'POST', noteData); + return response.data; + } + + /** + * 创建ShopYY订单 + * @param site 站点配置 + * @param data 订单数据 + * @returns 创建结果 + */ + async createOrder(site: any, data: any): Promise { + // ShopYY API: POST /orders + const response = await this.request(site, 'orders', 'POST', data); + return response.data; + } + + /** + * 删除ShopYY订单 + * @param site 站点配置 + * @param orderId 订单ID + * @returns 删除结果 + */ + async deleteOrder(site: any, orderId: string | number): Promise { + try { + // ShopYY API: DELETE /orders/{id} + await this.request(site, `orders/${orderId}`, 'DELETE'); + return true; + } catch (error) { + console.error('删除ShopYY订单失败:', error); + return false; + } + } + + /** + * 批量处理ShopYY产品 + * @param site 站点配置 + * @param data 批量操作数据 + * @returns 处理结果 + */ + async batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise { + // ShopYY API: POST /products/batch + const response = await this.request(site, 'products/batch', 'POST', data); + return response.data; + } + + /** + * 获取ShopYY客户列表 + * @param site 站点配置 + * @param params 查询参数 + * @returns 分页客户列表 + */ + async fetchCustomersPaged(site: any, params: any): Promise { + // ShopYY API: GET /customers + const { items, total, totalPages, page, per_page } = + await this.fetchResourcePaged(site, 'customers', params); + return { + items, + total, + totalPages, + page, + per_page + }; + } + + /** + * 获取单个ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @returns 客户详情 + */ + async getCustomer(site: any, customerId: string | number): Promise { + // ShopYY API: GET /customers/{id} + const response = await this.request(site, `customers/${customerId}`, 'GET'); + return response.data; + } + + /** + * 创建ShopYY客户 + * @param site 站点配置 + * @param data 客户数据 + * @returns 创建结果 + */ + async createCustomer(site: any, data: any): Promise { + // ShopYY API: POST /customers + const customerData = { + firstname: data.first_name || '', + lastname: data.last_name || '', + email: data.email || '', + phone: data.phone || '', + password: data.password || '' + }; + const response = await this.request(site, 'customers', 'POST', customerData); + return response.data; + } + + /** + * 更新ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateCustomer(site: any, customerId: string | number, data: any): Promise { + // ShopYY API: PUT /customers/{id} + const customerData = { + firstname: data.first_name || '', + lastname: data.last_name || '', + email: data.email || '', + phone: data.phone || '' + }; + const response = await this.request(site, `customers/${customerId}`, 'PUT', customerData); + return response.data; + } + + /** + * 删除ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @returns 删除结果 + */ + async deleteCustomer(site: any, customerId: string | number): Promise { + try { + // ShopYY API: DELETE /customers/{id} + await this.request(site, `customers/${customerId}`, 'DELETE'); + return true; + } catch (error) { + console.error('删除ShopYY客户失败:', error); + return false; + } + } +} diff --git a/src/service/site-api.service.ts b/src/service/site-api.service.ts new file mode 100644 index 0000000..9592945 --- /dev/null +++ b/src/service/site-api.service.ts @@ -0,0 +1,37 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { ShopyyAdapter } from '../adapter/shopyy.adapter'; +import { WooCommerceAdapter } from '../adapter/woocommerce.adapter'; +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { ShopyyService } from './shopyy.service'; +import { SiteService } from './site.service'; +import { WPService } from './wp.service'; + +@Provide() +export class SiteApiService { + @Inject() + siteService: SiteService; + + @Inject() + wpService: WPService; + + @Inject() + shopyyService: ShopyyService; + + async getAdapter(siteId: number): Promise { + const site = await this.siteService.get(siteId, true); + if (!site) { + throw new Error(`Site ${siteId} not found`); + } + + if (site.type === 'woocommerce') { + if (!site?.consumerKey || !site.consumerSecret || !site.apiUrl) { + throw new Error('站点配置缺少 consumerKey/consumerSecret/apiUrl'); + } + return new WooCommerceAdapter(site, this.wpService); + } else if (site.type === 'shopyy') { + return new ShopyyAdapter(site, this.shopyyService); + } + + throw new Error(`Unsupported site type: ${site.type}`); + } +} diff --git a/src/service/site.service.ts b/src/service/site.service.ts index ffc7b0c..f7f883d 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -5,6 +5,7 @@ import { Site } from '../entity/site.entity'; import { WpSite } from '../interface'; import { CreateSiteDTO, UpdateSiteDTO } from '../dto/site.dto'; import { Area } from '../entity/area.entity'; +import { StockPoint } from '../entity/stock_point.entity'; @Provide() @Scope(ScopeEnum.Singleton) @@ -15,6 +16,9 @@ export class SiteService { @InjectEntityModel(Area) areaModel: Repository; + @InjectEntityModel(StockPoint) + stockPointModel: Repository; + async syncFromConfig(sites: WpSite[] = []) { // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) for (const siteConfig of sites) { @@ -41,7 +45,7 @@ export class SiteService { async create(data: CreateSiteDTO) { // 从 DTO 中分离出区域代码和其他站点数据 - const { areas: areaCodes, ...restData } = data; + const { areas: areaCodes, stockPointIds, ...restData } = data; const newSite = new Site(); Object.assign(newSite, restData); @@ -56,6 +60,16 @@ export class SiteService { newSite.areas = []; } + // 如果传入了仓库点 ID,则查询并关联 StockPoint 实体 + if (stockPointIds && stockPointIds.length > 0) { + const stockPoints = await this.stockPointModel.findBy({ + id: In(stockPointIds.map(Number)), + }); + newSite.stockPoints = stockPoints; + } else { + newSite.stockPoints = []; + } + // 使用 save 方法保存实体及其关联关系 await this.siteModel.save(newSite); return true; @@ -63,11 +77,12 @@ export class SiteService { async update(id: string | number, data: UpdateSiteDTO) { // 从 DTO 中分离出区域代码和其他站点数据 - const { areas: areaCodes, ...restData } = data; + const { areas: areaCodes, stockPointIds, ...restData } = data; // 首先,根据 ID 查找要更新的站点实体 const siteToUpdate = await this.siteModel.findOne({ where: { id: Number(id) }, + relations: ['areas', 'stockPoints'], }); if (!siteToUpdate) { // 如果找不到站点,则操作失败 @@ -100,16 +115,28 @@ export class SiteService { } } + // 如果 DTO 中传入了 stockPointIds 字段(即使是空数组),也要更新关联关系 + if (stockPointIds !== undefined) { + if (stockPointIds.length > 0) { + const stockPoints = await this.stockPointModel.findBy({ + id: In(stockPointIds.map(Number)), + }); + siteToUpdate.stockPoints = stockPoints; + } else { + siteToUpdate.stockPoints = []; + } + } + // 使用 save 方法保存实体及其更新后的关联关系 await this.siteModel.save(siteToUpdate); return true; } - async get(id: string | number, includeSecret = false) { + async get(id: string | number, includeSecret = false):Promise { // 根据主键获取站点,并使用 relations 加载关联的 areas const site = await this.siteModel.findOne({ where: { id: Number(id) }, - relations: ['areas'], + relations: ['areas', 'stockPoints'], }); if (!site) { return null; @@ -161,7 +188,7 @@ export class SiteService { where, skip: (current - 1) * pageSize, take: pageSize, - relations: ['areas'], + relations: ['areas', 'stockPoints'], }); // 根据 includeSecret 决定是否脱敏返回密钥字段 const data = includeSecret diff --git a/src/service/subscription.service.ts b/src/service/subscription.service.ts index 4d03884..0b17f1f 100644 --- a/src/service/subscription.service.ts +++ b/src/service/subscription.service.ts @@ -20,9 +20,26 @@ export class SubscriptionService { * - 从 WooCommerce 拉取订阅并逐条入库/更新 */ async syncSubscriptions(siteId: number) { - const subs = await this.wpService.getSubscriptions(siteId); - for (const sub of subs) { - await this.syncSingleSubscription(siteId, sub); + try { + const subs = await this.wpService.getSubscriptions(siteId); + let successCount = 0; + let failureCount = 0; + for (const sub of subs) { + try { + await this.syncSingleSubscription(siteId, sub); + successCount++; + } catch (error) { + console.error(`同步订阅 ${sub.id} 失败:`, error); + failureCount++; + } + } + return { + success: failureCount === 0, + message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, + }; + } catch (error) { + console.error('同步订阅失败:', error); + return { success: false, message: `同步失败: ${error.message}` }; } } diff --git a/src/service/template.service.ts b/src/service/template.service.ts index 63e3639..3c10722 100644 --- a/src/service/template.service.ts +++ b/src/service/template.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Template } from '../entity/template.entity'; import { CreateTemplateDTO, UpdateTemplateDTO } from '../dto/template.dto'; import { Eta } from 'eta'; +import { generateTestDataFromEta } from '../utils/testdata.util'; /** * @service TemplateService 模板服务 @@ -51,6 +52,12 @@ export class TemplateService { // 设置模板的名称和值 template.name = templateData.name; template.value = templateData.value; + if (templateData.testData && templateData.testData.trim().length > 0) { + template.testData = templateData.testData; + } else { + const obj = generateTestDataFromEta(template.value); + template.testData = JSON.stringify(obj); + } // 保存新模板到数据库 return this.templateModel.save(template); } @@ -74,6 +81,12 @@ export class TemplateService { // 更新模板的名称和值 template.name = templateData.name; template.value = templateData.value; + if (templateData.testData && templateData.testData.trim().length > 0) { + template.testData = templateData.testData; + } else { + const obj = generateTestDataFromEta(template.value); + template.testData = JSON.stringify(obj); + } // 保存更新后的模板到数据库 return this.templateModel.save(template); } @@ -117,4 +130,22 @@ export class TemplateService { // 使用 Eta 渲染 return this.eta.renderString(template.value, data); } + + /** + * 回填所有缺失 testData 的模板 + * @returns 更新的模板数量 + */ + async backfillMissingTestData(): Promise { + const items = await this.templateModel.find({ where: { } }); + let updated = 0; + for (const t of items) { + if (!t.testData || t.testData.trim().length === 0) { + const obj = generateTestDataFromEta(t.value); + t.testData = JSON.stringify(obj); + await this.templateModel.save(t); + updated++; + } + } + return updated; + } } diff --git a/src/service/user.service.ts b/src/service/user.service.ts index 372922f..676bf9c 100644 --- a/src/service/user.service.ts +++ b/src/service/user.service.ts @@ -109,6 +109,10 @@ export class UserService { isActive?: boolean; isSuper?: boolean; isAdmin?: boolean; + } = {}, + sorter: { + field?: string; + order?: 'ASC' | 'DESC'; } = {} ) { // 条件判断:构造 where 条件 @@ -119,11 +123,15 @@ export class UserService { if (typeof filters.isAdmin === 'boolean') where.isAdmin = filters.isAdmin; // 按管理员过滤 if (filters.remark) where.remark = Like(`%${filters.remark}%`); // 备注模糊搜索 + const validSortFields = ['id', 'username', 'isActive', 'isSuper', 'isAdmin', 'remark']; + const sortField = validSortFields.includes(sorter.field) ? sorter.field : 'id'; + const sortOrder = sorter.order === 'ASC' ? 'ASC' : 'DESC'; + const [items, total] = await this.userModel.findAndCount({ where, skip: (current - 1) * pageSize, take: pageSize, - order: { id: 'DESC' }, + order: { [sortField]: sortOrder }, }); return { items, total, current, pageSize }; } diff --git a/src/service/wp.service.ts b/src/service/wp.service.ts index e56b2d5..07d14c8 100644 --- a/src/service/wp.service.ts +++ b/src/service/wp.service.ts @@ -4,11 +4,13 @@ import WooCommerceRestApi, { WooCommerceRestApiVersion } from '@woocommerce/wooc import { WpProduct } from '../entity/wp_product.entity'; import { Variation } from '../entity/variation.entity'; import { UpdateVariationDTO, UpdateWpProductDTO } from '../dto/wp_product.dto'; -import { ProductStatus, ProductStockStatus } from '../enums/base.enum'; import { SiteService } from './site.service'; +import { IPlatformService } from '../interface/platform.interface'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; @Provide() -export class WPService { +export class WPService implements IPlatformService { @Inject() private readonly siteService: SiteService; @@ -44,6 +46,14 @@ export class WPService { }); } + /** + * 通用分页获取资源 + */ + public async fetchResourcePaged(site: any, resource: string, params: Record = {}) { + const api = this.createApi(site, 'wc/v3'); + return this.sdkGetPage(api, resource, params); + } + /** * 通过 SDK 获取单页数据,并返回数据与 totalPages */ @@ -64,13 +74,9 @@ export class WPService { * 通过 SDK 聚合分页数据,返回全部数据 */ private async sdkGetAll(api: WooCommerceRestApi, resource: string, params: Record = {}, maxPages: number = 50): Promise { - const result: T[] = []; - for (let page = 1; page <= maxPages; page++) { - const { items, totalPages } = await this.sdkGetPage(api, resource, { ...params, page }); - result.push(...items); - if (page >= totalPages) break; - } - return result; + // 直接传入较大的per_page参数,一次性获取所有数据 + const { items } = await this.sdkGetPage(api, resource, { ...params, per_page: 100 }); + return items; } /** @@ -157,14 +163,14 @@ export class WPService { return allData; } - async getProducts(site: any): Promise { + async getProducts(site: any, page: number = 1, pageSize: number = 100): Promise { const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll(api, 'products'); + return await this.sdkGetPage(api, 'products', { page, per_page: pageSize }); } - async getVariations(site: any, productId: number): Promise { + async getVariations(site: any, productId: number, page: number = 1, pageSize: number = 100): Promise { const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll(api, `products/${productId}/variations`); + return await this.sdkGetPage(api, `products/${productId}/variations`, { page, per_page: pageSize }); } async getVariation( @@ -186,23 +192,23 @@ export class WPService { const res = await api.get(`orders/${orderId}`); return res.data as Record; } - async getOrders(siteId: number): Promise[]> { - const site = await this.siteService.get(siteId); - const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll>(api, 'orders'); + async getOrders(site: any | number, page: number = 1, pageSize: number = 100): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; + const api = this.createApi(siteConfig, 'wc/v3'); + return await this.sdkGetPage>(api, 'orders', { page, per_page: pageSize }); } /** * 获取 WooCommerce Subscriptions * 优先尝试 wc/v1/subscriptions(Subscriptions 插件提供),失败时回退 wc/v3/subscriptions. - * 返回所有分页合并后的订阅数组. */ - async getSubscriptions(siteId: number): Promise[]> { - const site = await this.siteService.get(siteId); + async getSubscriptions(site: any | number, page: number = 1, pageSize: number = 100): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; // 优先使用 Subscriptions 命名空间 wcs/v1,失败回退 wc/v3 - const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll>(api, 'subscriptions'); - + const api = this.createApi(siteConfig, 'wc/v3'); + return await this.sdkGetPage>(api, 'subscriptions', { page, per_page: pageSize }); } async getOrderRefund( @@ -217,12 +223,15 @@ export class WPService { } async getOrderRefunds( - siteId: string, - orderId: number - ): Promise[]> { - const site = await this.siteService.get(siteId); - const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll>(api, `orders/${orderId}/refunds`); + site: any | string, + orderId: number, + page: number = 1, + pageSize: number = 100 + ): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site; + const api = this.createApi(siteConfig, 'wc/v3'); + return await this.sdkGetPage>(api, `orders/${orderId}/refunds`, { page, per_page: pageSize }); } async getOrderNote( @@ -237,38 +246,42 @@ export class WPService { } async getOrderNotes( - siteId: string, - orderId: number - ): Promise[]> { - const site = await this.siteService.get(siteId); - const api = this.createApi(site, 'wc/v3'); - return await this.sdkGetAll>(api, `orders/${orderId}/notes`); + site: any | string, + orderId: number, + page: number = 1, + pageSize: number = 100 + ): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site; + const api = this.createApi(siteConfig, 'wc/v3'); + return await this.sdkGetPage>(api, `orders/${orderId}/notes`, { page, per_page: pageSize }); } - async updateData( - endpoint: string, + + /** + * 创建 WooCommerce 产品 + * @param site 站点配置 + * @param data 产品数据 + */ + async createProduct( site: any, - data: Record - ): Promise { - const apiUrl = site.apiUrl; - const { consumerKey, consumerSecret } = site; - const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( - 'base64' - ); - const config: AxiosRequestConfig = { - method: 'PUT', - // 构建 URL,规避多/或少/问题 - url: this.buildURL(apiUrl, '/wp-json', endpoint), - headers: { - Authorization: `Basic ${auth}`, - }, - data, - }; + data: any + ): Promise { + const api = this.createApi(site, 'wc/v3'); + // 确保价格为字符串 + if (data.regular_price !== undefined && data.regular_price !== null) { + data.regular_price = String(data.regular_price); + } + if (data.sale_price !== undefined && data.sale_price !== null) { + data.sale_price = String(data.sale_price); + } + try { - await axios.request(config); - return true; + const response = await api.post('products', data); + return response.data; } catch (error) { - return false; + console.error('创建产品失败:', error.response?.data || error.message); + throw error; } } @@ -281,33 +294,108 @@ export class WPService { site: any, productId: string, data: UpdateWpProductDTO - ): Promise { + ): Promise { const { regular_price, sale_price, ...params } = data; - return await this.updateData(`/wc/v3/products/${productId}`, site, { - ...params, - regular_price: regular_price ? regular_price.toString() : null, - sale_price: sale_price ? sale_price.toString() : null, - }); + const api = this.createApi(site, 'wc/v3'); + const updateData: any = { ...params }; + if (regular_price !== undefined && regular_price !== null) { + updateData.regular_price = String(regular_price); + } + if (sale_price !== undefined && sale_price !== null) { + updateData.sale_price = String(sale_price); + } + + try { + const response = await api.put(`products/${productId}`, updateData); + return response.data; + } catch (error) { + console.error('更新产品失败:', error.response?.data || error.message); + throw new Error(`更新产品失败: ${error.response?.data?.message || error.message}`); + } } - /** + /** * 更新 WooCommerce 产品 上下架状态 * @param productId 产品 ID * @param status 状态 - * @param stock_status 上下架状态 + * @param stockStatus 库存状态 */ async updateProductStatus( site: any, productId: string, - status: ProductStatus, - stock_status: ProductStockStatus - ): Promise { - const res = await this.updateData(`/wc/v3/products/${productId}`, site, { - status, - manage_stock: false, // 为true的时候,用quantity控制库存,为false时,直接用stock_status控制 - stock_status, - }); - return res; + status: string, + stockStatus: string + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + await api.put(`products/${productId}`, { + status, + manage_stock: false, // 为true的时候,用quantity控制库存,为false时,直接用stock_status控制 + stock_status: stockStatus, + }); + return true; + } catch (error) { + console.error('更新产品上下架状态失败:', error.response?.data || error.message); + throw new Error(`更新产品上下架状态失败: ${error.response?.data?.message || error.message}`); + } + } + + /** + * 更新 WooCommerce 产品库存 + * @param site 站点配置 + * @param productId 产品 ID + * @param quantity 库存数量 + * @param stockStatus 库存状态 + */ + async updateProductStock( + site: any, + productId: string, + quantity: number, + stockStatus: string + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + await api.put(`products/${productId}`, { + manage_stock: true, + stock_quantity: quantity, + stock_status: stockStatus, + }); + return true; + } catch (error) { + console.error('更新产品库存失败:', error.response?.data || error.message); + // throw new Error(`更新产品库存失败: ${error.response?.data?.message || error.message}`); + // 为了不打断批量同步,这里记录错误但不抛出 + return false; + } + } + + /** + * 更新 WooCommerce 产品变体库存 + * @param site 站点配置 + * @param productId 产品 ID + * @param variationId 变体 ID + * @param quantity 库存数量 + * @param stockStatus 库存状态 + */ + async updateProductVariationStock( + site: any, + productId: string, + variationId: string, + quantity: number, + stockStatus: string + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + await api.put(`products/${productId}/variations/${variationId}`, { + manage_stock: true, + stock_quantity: quantity, + stock_status: stockStatus, + }); + return true; + } catch (error) { + console.error('更新产品变体库存失败:', error.response?.data || error.message); + return false; + } } /** @@ -321,17 +409,24 @@ export class WPService { productId: string, variationId: string, data: Partial - ): Promise { + ): Promise { const { regular_price, sale_price, ...params } = data; - return await this.updateData( - `/wc/v3/products/${productId}/variations/${variationId}`, - site, - { - ...params, - regular_price: regular_price ? regular_price.toString() : null, - sale_price: sale_price ? sale_price.toString() : null, - } - ); + const api = this.createApi(site, 'wc/v3'); + const updateData: any = { ...params }; + if (regular_price !== undefined && regular_price !== null) { + updateData.regular_price = String(regular_price); + } + if (sale_price !== undefined && sale_price !== null) { + updateData.sale_price = String(sale_price); + } + + try { + await api.put(`products/${productId}/variations/${variationId}`, updateData); + return true; + } catch (error) { + console.error('更新产品变体失败:', error.response?.data || error.message); + throw new Error(`更新产品变体失败: ${error.response?.data?.message || error.message}`); + } } /** @@ -341,8 +436,15 @@ export class WPService { site: any, orderId: string, data: Record - ): Promise { - return await this.updateData(`/wc/v3/orders/${orderId}`, site, data); + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + await api.put(`orders/${orderId}`, data); + return true; + } catch (error) { + console.error('更新订单失败:', error.response?.data || error.message); + throw new Error(`更新订单失败: ${error.response?.data?.message || error.message}`); + } } async createShipment( @@ -377,7 +479,7 @@ export class WPService { site: any, orderId: string, trackingId: string, - ): Promise { + ): Promise { const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site; const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString( @@ -401,7 +503,88 @@ export class WPService { Authorization: `Basic ${auth}`, }, }; - return await axios.request(config); + try { + await axios.request(config); + return true; + } catch (error) { + console.error('删除物流信息失败:', error.response?.data || error.message); + return false; + } + } + + /** + * 批量处理产品 (Create, Update, Delete) + * @param site 站点配置 + * @param data 批量操作数据 { create?: [], update?: [], delete?: [] } + */ + async batchProcessProducts( + site: any, + data: { create?: any[]; update?: any[]; delete?: any[] } + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + const response = await api.post('products/batch', data); + return response.data; + } catch (error) { + console.error('批量处理产品失败:', error.response?.data || error.message); + throw error; + } + } + + /** + * 获取所有产品分类 + * @param site 站点配置 + */ + async getCategories(site: any): Promise { + const api = this.createApi(site, 'wc/v3'); + return await this.sdkGetAll(api, 'products/categories'); + } + + /** + * 批量处理产品分类 + * @param site 站点配置 + * @param data { create?: [], update?: [], delete?: [] } + */ + async batchProcessCategories( + site: any, + data: { create?: any[]; update?: any[]; delete?: any[] } + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + const response = await api.post('products/categories/batch', data); + return response.data; + } catch (error) { + console.error('批量处理产品分类失败:', error.response?.data || error.message); + throw error; + } + } + + /** + * 获取所有产品标签 + * @param site 站点配置 + */ + async getTags(site: any): Promise { + const api = this.createApi(site, 'wc/v3'); + return await this.sdkGetAll(api, 'products/tags'); + } + + /** + * 批量处理产品标签 + * @param site 站点配置 + * @param data { create?: [], update?: [], delete?: [] } + */ + async batchProcessTags( + site: any, + data: { create?: any[]; update?: any[]; delete?: any[] } + ): Promise { + const api = this.createApi(site, 'wc/v3'); + try { + const response = await api.post('products/tags/batch', data); + return response.data; + } catch (error) { + console.error('批量处理产品标签失败:', error.response?.data || error.message); + throw error; + } } /** @@ -436,4 +619,150 @@ export class WPService { totalPages }; } -} \ No newline at end of file + + /** + * 上传媒体文件 + * @param siteId 站点 ID + * @param file 文件对象 + */ + async createMedia(siteId: number, file: any): Promise { + const site = await this.siteService.get(siteId, true); + if (!site) { + throw new Error('站点不存在'); + } + const endpoint = 'wp/v2/media'; + const apiUrl = site.apiUrl; + const { consumerKey, consumerSecret } = site as any; + const url = this.buildURL(apiUrl, '/wp-json', endpoint); + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); + + const formData = new FormData(); + // 假设 file 是 MidwayJS 的 file 对象 + // MidwayJS 上传文件通常在 tmp 目录,需要读取流 + formData.append('file', fs.createReadStream(file.data), { + filename: file.filename, + contentType: file.mimeType, + }); + + // Axios headers for multipart + const headers = { + Authorization: `Basic ${auth}`, + 'Content-Disposition': `attachment; filename=${file.filename}`, + ...formData.getHeaders(), + }; + + const response = await axios.post(url, formData, { headers }); + return response.data; + } + + /** + * 更新媒体信息 + * @param siteId 站点 ID + * @param mediaId 媒体 ID + * @param data 更新数据 (title, caption, description, alt_text) + */ + async updateMedia(siteId: number, mediaId: number, data: any): Promise { + const site = await this.siteService.get(siteId, true); + if (!site) { + throw new Error('站点不存在'); + } + const endpoint = `wp/v2/media/${mediaId}`; + const apiUrl = site.apiUrl; + const { consumerKey, consumerSecret } = site as any; + const url = this.buildURL(apiUrl, '/wp-json', endpoint); + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); + + const response = await axios.post(url, data, { + headers: { Authorization: `Basic ${auth}` }, + }); + return response.data; + } + + /** + * 删除媒体文件 + * @param siteId 站点 ID + * @param mediaId 媒体 ID + * @param force 是否强制删除(绕过回收站) + */ + async deleteMedia(siteId: number, mediaId: number, force: boolean = true): Promise { + const site = await this.siteService.get(siteId, true); + if (!site) { + throw new Error('站点不存在'); + } + const endpoint = `wp/v2/media/${mediaId}`; + const apiUrl = site.apiUrl; + const { consumerKey, consumerSecret } = site as any; + const url = this.buildURL(apiUrl, '/wp-json', endpoint); + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); + + const response = await axios.delete(url, { + headers: { Authorization: `Basic ${auth}` }, + params: { force }, + }); + return response.data; + } + + async getCustomers(siteId: number, page: number = 1, perPage: number = 20): Promise<{ items: any[], total: number, totalPages: number }> { + const site = await this.siteService.get(siteId); + if (!site) { + throw new Error(`Site ${siteId} not found`); + } + + if (site.type === 'shopyy') { + return { items: [], total: 0, totalPages: 0 }; + } + + const api = this.createApi(site, 'wc/v3'); + return await this.sdkGetPage(api, 'customers', { page, per_page: perPage }); + } + + async ensureTags(site: any, tagNames: string[]): Promise<{ id: number; name: string }[]> { + if (!tagNames || tagNames.length === 0) return []; + + const allTags = await this.getTags(site); + const existingTagMap = new Map(allTags.map((t) => [t.name, t.id])); + const missingTags = tagNames.filter((name) => !existingTagMap.has(name)); + + if (missingTags.length > 0) { + const createPayload = missingTags.map((name) => ({ name })); + const createdTagsResult = await this.batchProcessTags(site, { create: createPayload }); + if (createdTagsResult && createdTagsResult.create) { + createdTagsResult.create.forEach((t) => { + if (t.id && t.name) existingTagMap.set(t.name, t.id); + }); + } + } + + return tagNames + .map((name) => { + const id = existingTagMap.get(name); + return id ? { id, name } : null; + }) + .filter((t) => t !== null) as { id: number; name: string }[]; + } + + async ensureCategories(site: any, categoryNames: string[]): Promise<{ id: number; name: string }[]> { + if (!categoryNames || categoryNames.length === 0) return []; + + const allCategories = await this.getCategories(site); + const existingCatMap = new Map(allCategories.map((c) => [c.name, c.id])); + const missingCategories = categoryNames.filter((name) => !existingCatMap.has(name)); + + if (missingCategories.length > 0) { + const createPayload = missingCategories.map((name) => ({ name })); + const createdCatsResult = await this.batchProcessCategories(site, { create: createPayload }); + if (createdCatsResult && createdCatsResult.create) { + createdCatsResult.create.forEach((c) => { + if (c.id && c.name) existingCatMap.set(c.name, c.id); + }); + } + } + + return categoryNames + .map((name) => { + const id = existingCatMap.get(name); + return id ? { id, name } : null; + }) + .filter((c) => c !== null) as { id: number; name: string }[]; + } +} diff --git a/src/service/wp_product.service.ts b/src/service/wp_product.service.ts index 31cedcd..6f20d91 100644 --- a/src/service/wp_product.service.ts +++ b/src/service/wp_product.service.ts @@ -1,18 +1,24 @@ +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 } from '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 { // 移除配置中的站点数组,统一从数据库获取站点信息 @@ -23,12 +29,21 @@ export class WpProductService { @Inject() private readonly siteService: SiteService; + @Inject() + private readonly stockService: StockService; + @InjectEntityModel(WpProduct) wpProductModel: Repository; @InjectEntityModel(Variation) variationModel: Repository; + @InjectEntityModel(Product) + productModel: Repository; + + @InjectEntityModel(ProductSiteSku) + productSiteSkuModel: Repository; + async syncAllSites() { // 从数据库获取所有启用的站点,并逐站点同步产品与变体 @@ -44,48 +59,558 @@ export class WpProductService { } } } - // 同步一个网站 - async syncSite(siteId: number) { - // 通过数据库获取站点并转换为 WpSite,用于后续 WooCommerce 同步 + + 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 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 products = await this.productModel.find({ + where: { id: In(productIds) }, + }); + this.logToFile(`[BatchSync] Found ${products.length} products in local DB`); - const externalIds = rawResult.map(item => item.externalProductId); + const batchData = { + create: [], + update: [], + }; - const excludeValues = []; + const skuToProductMap = new Map(); - const products = await this.wpApiService.getProducts(site); for (const product of products) { - excludeValues.push(String(product.id)); - const variations = - product.type === 'variable' - ? await this.wpApiService.getVariations(site, product.id) - : []; + 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 } + }); - await this.syncProductAndVariations(site.id, product, variations); + 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; } - 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(); + 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); + } - 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(); + 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, code }, + }); + if (!existingSiteSku) { + this.logToFile(`[BatchSync] Creating ProductSiteSku for productId=${localProduct.id} code=${code}`); + await this.productSiteSkuModel.save({ + productId: localProduct.id, + code, + }); + } else { + this.logToFile(`[BatchSync] ProductSiteSku already exists for productId=${localProduct.id} code=${code}`); + } + } else { + this.logToFile(`[BatchSync] Warning: Local product not found in map for SKU=${item.sku || originalSku}`); + } + + // Sync back to local WpProduct table + await this.syncProductAndVariations(siteId, item, []); + } else if (item.error) { + // Handle duplicated SKU error by linking to existing remote product + if (item.error.code === 'product_invalid_sku' && item.error.data && item.error.data.resource_id) { + const recoveredSku = item.error.data.unique_sku; + const resourceId = item.error.data.resource_id; + this.logToFile(`[BatchSync] Recovering from duplicate SKU error. SKU=${recoveredSku}, ID=${resourceId}`); + + let localProduct = skuToProductMap.get(recoveredSku); + + // Fallback to original SKU + if (!localProduct && originalSku) { + localProduct = skuToProductMap.get(originalSku); + } + + if (localProduct) { + // Construct a fake product object to sync local DB + const fakeProduct = { + id: resourceId, + sku: recoveredSku, // Use the actual SKU on server + name: localProduct.name, + type: localProduct.type === 'single' ? 'simple' : (localProduct.type === 'bundle' ? 'bundle' : 'simple'), + status: 'publish', + regular_price: localProduct.price ? String(localProduct.price) : '0', + sale_price: localProduct.promotionPrice ? String(localProduct.promotionPrice) : '', + on_sale: !!localProduct.promotionPrice, + metadata: [], + tags: [], + categories: [] + }; + + try { + await this.syncProductAndVariations(siteId, fakeProduct as any, []); + this.logToFile(`[BatchSync] Successfully linked local product to existing remote product ID=${resourceId}`); + } catch (e) { + this.logToFile(`[BatchSync] Failed to link recovered product:`, e); + } + } else { + this.logToFile(`[BatchSync] Warning: Local product not found in map for recovered SKU=${recoveredSku} or original SKU=${originalSku}`); + } + } else { + this.logToFile(`[BatchSync] Item Error: SKU=${originalSku || 'unknown'}`, item.error); + } + } else { + this.logToFile(`[BatchSync] Unknown item format:`, item); + } + }; + + if (result.create) { + for (let i = 0; i < result.create.length; i++) { + await processResultItem(result.create[i], batchData.create, i); + } + } + + if (result.update) { + for (let i = 0; i < result.update.length; i++) { + await processResultItem(result.update[i], batchData.update, i); + } + } + + return result; + } + + async batchUpdateTags(ids: number[], tags: string[]) { + if (!ids || ids.length === 0 || !tags || tags.length === 0) return; + + const products = await this.wpProductModel.find({ + where: { id: In(ids) }, + }); + + // Group by siteId + const productsBySite = new Map(); + for (const product of products) { + if (!productsBySite.has(product.siteId)) { + productsBySite.set(product.siteId, []); + } + productsBySite.get(product.siteId).push(product); + } + + for (const [siteId, siteProducts] of productsBySite) { + const site = await this.siteService.get(siteId, true); + if (!site) continue; + + const batchData = { + create: [], + update: [], + }; + + for (const product of siteProducts) { + const currentTags = product.tags || []; + // Add new tags, avoiding duplicates by name + const newTags = [...currentTags]; + const tagsToAdd = []; + + for (const tag of tags) { + if (!newTags.some(t => t.name === tag)) { + const newTagObj = { name: tag, id: 0, slug: '' }; + newTags.push(newTagObj); + tagsToAdd.push(newTagObj); + } + } + + if (tagsToAdd.length > 0) { + batchData.update.push({ + id: product.externalProductId, + tags: newTags.map(t => (t.id ? { id: t.id } : { name: t.name })), + }); + // Update local DB optimistically + // Generate slug simply + tagsToAdd.forEach(t => (t.slug = t.name.toLowerCase().replace(/\s+/g, '-'))); + product.tags = newTags; + await this.wpProductModel.save(product); + } + } + + if (batchData.update.length > 0) { + await this.wpApiService.batchProcessProducts(site, batchData); + } + } + } + + async batchUpdateProducts(dto: BatchUpdateProductsDTO) { + const { ids, ...updates } = dto; + if (!ids || ids.length === 0) return; + + const products = await this.wpProductModel.find({ + where: { id: In(ids) }, + }); + + // Group by siteId + const productsBySite = new Map(); + for (const product of products) { + if (!productsBySite.has(product.siteId)) { + productsBySite.set(product.siteId, []); + } + productsBySite.get(product.siteId).push(product); + } + + for (const [siteId, siteProducts] of productsBySite) { + const site = await this.siteService.get(siteId, true); + if (!site) continue; + + // Resolve Categories if needed + let categoryIds: number[] = []; + if (updates.categories && updates.categories.length > 0) { + // 1. Get all existing categories + const allCategories = await this.wpApiService.getCategories(site); + const existingCatMap = new Map(allCategories.map(c => [c.name, c.id])); + + // 2. Identify missing categories + const missingCategories = updates.categories.filter(name => !existingCatMap.has(name)); + + // 3. Create missing categories + if (missingCategories.length > 0) { + const createPayload = missingCategories.map(name => ({ name })); + const createdCatsResult = await this.wpApiService.batchProcessCategories(site, { create: createPayload }); + if (createdCatsResult && createdCatsResult.create) { + createdCatsResult.create.forEach(c => { + if (c.id && c.name) existingCatMap.set(c.name, c.id); + }); + } + } + + // 4. Collect all IDs + categoryIds = updates.categories + .map(name => existingCatMap.get(name)) + .filter(id => id !== undefined); + } + + // Resolve Tags if needed + let tagIds: number[] = []; + if (updates.tags && updates.tags.length > 0) { + // 1. Get all existing tags + const allTags = await this.wpApiService.getTags(site); + const existingTagMap = new Map(allTags.map(t => [t.name, t.id])); + + // 2. Identify missing tags + const missingTags = updates.tags.filter(name => !existingTagMap.has(name)); + + // 3. Create missing tags + if (missingTags.length > 0) { + const createPayload = missingTags.map(name => ({ name })); + const createdTagsResult = await this.wpApiService.batchProcessTags(site, { create: createPayload }); + if (createdTagsResult && createdTagsResult.create) { + createdTagsResult.create.forEach(t => { + if (t.id && t.name) existingTagMap.set(t.name, t.id); + }); + } + } + + // 4. Collect all IDs + tagIds = updates.tags + .map(name => existingTagMap.get(name)) + .filter(id => id !== undefined); + } + + const batchData = { + create: [], + update: [], + }; + + for (const product of siteProducts) { + const updateData: any = { + id: product.externalProductId, + }; + + if (updates.regular_price) updateData.regular_price = String(updates.regular_price); + if (updates.sale_price) updateData.sale_price = String(updates.sale_price); + if (updates.status) updateData.status = updates.status; + + if (categoryIds.length > 0) { + updateData.categories = categoryIds.map(id => ({ id })); + } + + if (tagIds.length > 0) { + updateData.tags = tagIds.map(id => ({ id })); + } + + batchData.update.push(updateData); + + // Optimistic update local DB + if (updates.regular_price) product.regular_price = updates.regular_price; + if (updates.sale_price) product.sale_price = updates.sale_price; + if (updates.status) product.status = updates.status as ProductStatus; + if (updates.categories) product.categories = updates.categories.map(c => ({ name: c, id: 0, slug: '' })); // simple mock + if (updates.tags) product.tags = updates.tags.map(t => ({ name: t, id: 0, slug: '' })); // simple mock + + await this.wpProductModel.save(product); + } + + if (batchData.update.length > 0) { + await this.wpApiService.batchProcessProducts(site, batchData); + } + } + } + + async importProducts(siteId: number, file: any) { + const site = await this.siteService.get(siteId, true); + if (!site) throw new Error('站点不存在'); + + const parser = fs + .createReadStream(file.data) + .pipe(parse({ + columns: true, + skip_empty_lines: true, + trim: true, + bom: true + })); + + let batch = []; + const batchSize = 50; + + for await (const record of parser) { + batch.push(record); + if (batch.length >= batchSize) { + await this.processImportBatch(siteId, site, batch); + batch = []; + } + } + + if (batch.length > 0) { + await this.processImportBatch(siteId, site, batch); + } + } + + private async processImportBatch(siteId: number, site: any, chunk: any[]) { + const batchData = { + create: [], + update: [], + }; + + for (const row of chunk) { + const sku = row['SKU'] || row['sku']; + if (!sku) continue; + + const existingProduct = await this.wpProductModel.findOne({ + where: { siteId, sku } + }); + + const productData: any = { + sku: sku, + name: row['Name'] || row['name'], + type: (row['Type'] || row['type'] || 'simple').toLowerCase(), + regular_price: row['Regular price'] || row['regular_price'], + sale_price: row['Sale price'] || row['sale_price'], + short_description: row['Short description'] || row['short_description'] || '', + description: row['Description'] || row['description'] || '', + }; + + if (productData.regular_price) productData.regular_price = String(productData.regular_price); + if (productData.sale_price) productData.sale_price = String(productData.sale_price); + + const imagesStr = row['Images'] || row['images']; + if (imagesStr) { + productData.images = imagesStr.split(',').map(url => ({ src: url.trim() })); + } + + if (existingProduct) { + batchData.update.push({ + id: existingProduct.externalProductId, + ...productData + }); + } else { + batchData.create.push(productData); + } + } + + if (batchData.create.length > 0 || batchData.update.length > 0) { + try { + const result = await this.wpApiService.batchProcessProducts(site, batchData); + await this.syncBackFromBatchResult(siteId, result); + } catch (e) { + console.error('Batch process error during import:', e); + } + } + } + + private async syncBackFromBatchResult(siteId: number, result: any) { + const processResultItem = async (item: any) => { + if (item.id) { + await this.syncProductAndVariations(siteId, item, []); + } + }; + + if (result.create) { + for (const item of result.create) { + await processResultItem(item); + } + } + if (result.update) { + for (const item of result.update) { + await processResultItem(item); + } + } + } + + + + // 同步产品库存到 Site + async syncProductStockToSite(siteId: number, sku: string) { + const site = await this.siteService.get(siteId, true); + if (!site) throw new Error('站点不存在'); + + // 获取站点绑定的仓库 + if (!site.stockPoints || site.stockPoints.length === 0) { + console.log(`站点 ${siteId} 未绑定任何仓库,跳过库存同步`); + return; + } + + // 获取产品在这些仓库的总库存 + const stockPointIds = site.stockPoints.map(sp => sp.id); + const stock = await this.stockService.stockModel + .createQueryBuilder('stock') + .select('SUM(stock.quantity)', 'total') + .where('stock.sku = :sku', { sku }) + .andWhere('stock.stockPointId IN (:...stockPointIds)', { stockPointIds }) + .getRawOne(); + + const quantity = stock && stock.total ? Number(stock.total) : 0; + const stockStatus = quantity > 0 ? ProductStockStatus.INSTOCK : ProductStockStatus.OUT_OF_STOCK; + + // 查找对应的 WpProduct 以获取 externalProductId + const wpProduct = await this.wpProductModel.findOne({ where: { siteId, sku } }); + if (wpProduct) { + // 更新 WooCommerce 库存 + await this.wpApiService.updateProductStock(site, wpProduct.externalProductId, quantity, stockStatus); + + // 更新本地 WpProduct 状态 + wpProduct.stock_quantity = quantity; + wpProduct.stockStatus = stockStatus; + await this.wpProductModel.save(wpProduct); + } else { + // 尝试查找变体 + const variation = await this.variationModel.findOne({ where: { siteId, sku } }); + if (variation) { + await this.wpApiService.updateProductVariationStock(site, variation.externalProductId, variation.externalVariationId, quantity, stockStatus); + // 变体表目前没有 stock_quantity 字段,如果需要可以添加 + } + } + } + + // 同步一个网站 + async syncSite(siteId: number) { + try { + // 通过数据库获取站点并转换为 WpSite,用于后续 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, + message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, + }; + } catch (error) { + console.error('同步站点产品失败:', error); + return { success: false, message: `同步失败: ${error.message}` }; } } @@ -130,11 +655,23 @@ export class WpProductService { ) { let existingProduct = await this.findProduct(siteId, productId); if (existingProduct) { - existingProduct.name = product.name; - existingProduct.sku = product.sku; - product.regular_price && - (existingProduct.regular_price = product.regular_price); - product.sale_price && (existingProduct.sale_price = product.sale_price); + 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); } } @@ -154,10 +691,12 @@ export class WpProductService { if (existingVariation) { existingVariation.name = variation.name; existingVariation.sku = variation.sku; - variation.regular_price && - (existingVariation.regular_price = variation.regular_price); - variation.sale_price && - (existingVariation.sale_price = variation.sale_price); + 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); } } @@ -175,9 +714,12 @@ export class WpProductService { existingProduct.status = product.status; existingProduct.type = product.type; existingProduct.sku = product.sku; - product.regular_price && - (existingProduct.regular_price = product.regular_price); - product.sale_price && (existingProduct.sale_price = product.sale_price); + 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; @@ -192,9 +734,9 @@ export class WpProductService { name: product.name, type: product.type, ...(product.regular_price - ? { regular_price: product.regular_price } + ? { regular_price: Number(product.regular_price) } : {}), - ...(product.sale_price ? { sale_price: product.sale_price } : {}), + ...(product.sale_price ? { sale_price: Number(product.sale_price) } : {}), on_sale: product.on_sale, metadata: product.metadata, tags: product.tags, @@ -203,6 +745,8 @@ export class WpProductService { await this.wpProductModel.save(existingProduct); } + await this.ensureSiteSku(product.sku, siteId, product.type); + // 2. 处理变体同步 if (product.type === 'variable') { const currentVariations = await this.variationModel.find({ @@ -219,6 +763,7 @@ export class WpProductService { } for (const variation of variations) { + await this.ensureSiteSku(variation.sku, siteId); const existingVariation = await this.findVariation( siteId, String(product.id), @@ -264,6 +809,7 @@ export class WpProductService { } 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({ @@ -303,7 +849,7 @@ export class WpProductService { } async getProductList(param: QueryWpProductDTO) { - const { current = 1, pageSize = 10, name, siteId, status } = param; + const { current = 1, pageSize = 10, name, siteId, status, skus } = param; // 第一步:先查询分页的产品 const where: any = {}; if (siteId) { @@ -317,6 +863,65 @@ export class WpProductService { 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({ @@ -343,12 +948,12 @@ export class WpProductService { .leftJoin( Product, 'product', - 'JSON_UNQUOTE(JSON_EXTRACT(wp_product.constitution, "$.sku")) = product.sku' + 'wp_product.sku = product.sku' ) .leftJoin( Product, 'variation_product', - 'JSON_UNQUOTE(JSON_EXTRACT(variation.constitution, "$.sku")) = variation_product.sku' + 'variation.sku = variation_product.sku' ) .select([ 'wp_product.*', @@ -362,7 +967,6 @@ export class WpProductService { 'variation.regular_price as variation_regular_price', 'variation.sale_price as variation_sale_price', 'variation.on_sale as variation_on_sale', - 'variation.constitution as variation_constitution', 'product.name as product_name', // 关联查询返回 product.name 'variation_product.name as variation_product_name', // 关联查询返回 variation 的产品 name ]) @@ -401,25 +1005,10 @@ export class WpProductService { obj[key.replace('variation_', '')] = row[key]; return obj; }, {}); - variation.constitution = - variation?.constitution?.map(item => { - const product = item.sku - ? { ...item, name: row.variation_product_name } - : item; - return product; - }) || []; product.variations.push(variation); } - product.constitution = - product?.constitution?.map(item => { - const productWithName = item.sku - ? { ...item, name: row.product_name } - : item; - return productWithName; - }) || []; - return acc; }, []); @@ -472,31 +1061,11 @@ export class WpProductService { return !!variationDuplicate; } - /** - * 设置产品或变体的构成成分 - */ - async setConstitution( - id: number, - isProduct: boolean, - constitution: { sku: string; quantity: number }[] - ): Promise { - if (isProduct) { - // 更新产品的 constitution - const product = await this.wpProductModel.findOne({ where: { id } }); - if (!product) { - throw new Error(`未找到 ID 为 ${id} 的产品`); - } - product.constitution = constitution; - await this.wpProductModel.save(product); - } else { - // 更新变体的 constitution - const variation = await this.variationModel.findOne({ where: { id } }); - if (!variation) { - throw new Error(`未找到 ID 为 ${id} 的变体`); - } - variation.constitution = constitution; - await this.variationModel.save(variation); - } + 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) { @@ -559,4 +1128,85 @@ export class WpProductService { 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: { code: 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, code: sku }, + }); + + if (!existingSiteSku) { + await this.productSiteSkuModel.save({ + productId: product.id, + code: sku, + }); + } + } + } } diff --git a/src/utils/testdata.util.ts b/src/utils/testdata.util.ts new file mode 100644 index 0000000..9ac4351 --- /dev/null +++ b/src/utils/testdata.util.ts @@ -0,0 +1,65 @@ +export function generateTestDataFromEta(template: string): Record { + const data: Record = {}; + + const tagRegex = /<%[\-=]?([\s\S]*?)%>/g; + const itPathRegex = /\bit\.([a-zA-Z0-9_$.\[\]]+)/g; + + const setPath = (path: string) => { + const parts: Array = []; + path.split('.').forEach((segment) => { + const arrMatch = segment.match(/^([a-zA-Z0-9_\$]+)(\[(\d+)\])?$/); + if (arrMatch) { + parts.push(arrMatch[1]); + if (arrMatch[3] !== undefined) { + parts.push(Number(arrMatch[3])); + } + } else { + parts.push(segment); + } + }); + + let cursor: any = data; + for (let i = 0; i < parts.length; i++) { + const key = parts[i]; + const next = parts[i + 1]; + const isArrayIndex = typeof key === 'number'; + + if (isArrayIndex) { + if (!Array.isArray(cursor)) { + cursor = []; + } + if (!cursor[key]) cursor[key] = {}; + cursor = cursor[key]; + continue; + } + + if (next === undefined) { + // leaf default value + cursor[key as string] = cursor[key as string] ?? 'sample'; + } else if (typeof next === 'number') { + if (!Array.isArray(cursor[key as string])) cursor[key as string] = []; + if (!cursor[key as string][next]) cursor[key as string][next] = {}; + cursor = cursor[key as string][next]; + } else { + if (cursor[key as string] == null || typeof cursor[key as string] !== 'object') { + cursor[key as string] = {}; + } + cursor = cursor[key as string]; + } + } + }; + + let m: RegExpExecArray | null; + while ((m = tagRegex.exec(template)) !== null) { + const inside = m[1]; + let mm: RegExpExecArray | null; + while ((mm = itPathRegex.exec(inside)) !== null) { + const raw = mm[1]; + // ignore method calls like it.arr.forEach -> we only keep path before method + const cleaned = raw.replace(/\b(forEach|map|filter|reduce|find|some|every|slice|splice)\b.*$/, ''); + if (cleaned) setPath(cleaned); + } + } + + return data; +} diff --git a/tsconfig.json b/tsconfig.json index 2b54fc6..fd2111a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,5 @@ "inlineSources": true // ✅ 把源码嵌入 map 文件,方便 VS Code 还原 }, - "exclude": ["*.js", "*.ts", "dist", "node_modules", "test"] + "exclude": ["*.js", "*.ts", "dist", "node_modules", "test", "scripts"] }