feat: 新增批量删除产品和同步库存SKU功能

refactor: 重构订单同步逻辑,增加成功失败统计

feat(template): 添加模板测试数据生成和回填功能

feat(site): 支持站点与仓库点关联

feat(user): 用户列表支持排序功能

refactor: 移除变体和产品的constitution字段,改用组件关联

feat(media): 实现媒体文件上传、更新和删除接口

feat: 新增平台服务工厂和站点适配器接口

fix: 修复产品同步和库存计算问题

docs: 更新DTO和接口文档

test: 更新模板种子数据

chore: 更新依赖和配置
This commit is contained in:
tikkhun 2025-12-10 15:32:29 +08:00
parent 40a445830b
commit 50317abff3
43 changed files with 4130 additions and 519 deletions

View File

@ -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<UnifiedPaginationDTO<UnifiedProductDTO>> {
const response = await this.shopyyService.fetchResourcePaged<any>(
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<UnifiedProductDTO> {
// 使用ShopyyService获取单个产品
const product = await this.shopyyService.getProduct(this.site, id);
return this.mapProduct(product);
}
async createProduct(data: Partial<UnifiedProductDTO>): Promise<UnifiedProductDTO> {
const res = await this.shopyyService.createProduct(this.site, data);
return this.mapProduct(res);
}
async updateProduct(id: string | number, data: Partial<UnifiedProductDTO>): Promise<UnifiedProductDTO> {
// 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<any> {
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<any[]> {
return await this.shopyyService.getOrderNotes(this.site, orderId);
}
async createOrderNote(orderId: string | number, data: any): Promise<any> {
return await this.shopyyService.createOrderNote(this.site, orderId, data);
}
async deleteProduct(id: string | number): Promise<boolean> {
// Use batch delete
await this.shopyyService.batchProcessProducts(this.site, { delete: [id] });
return true;
}
async getOrders(
params: UnifiedSearchParamsDTO
): Promise<UnifiedPaginationDTO<UnifiedOrderDTO>> {
const { items, total, totalPages, page, per_page } =
await this.shopyyService.fetchResourcePaged<any>(
this.site,
'orders',
params
);
return {
items: items.map(this.mapOrder.bind(this)),
total,
totalPages,
page,
per_page,
};
}
async getOrder(id: string | number): Promise<UnifiedOrderDTO> {
const data = await this.shopyyService.getOrder(String(this.site.id), String(id));
return this.mapOrder(data);
}
async createOrder(data: Partial<UnifiedOrderDTO>): Promise<UnifiedOrderDTO> {
const createdOrder = await this.shopyyService.createOrder(this.site, data);
return this.mapOrder(createdOrder);
}
async updateOrder(id: string | number, data: Partial<UnifiedOrderDTO>): Promise<boolean> {
return await this.shopyyService.updateOrder(this.site, String(id), data);
}
async deleteOrder(id: string | number): Promise<boolean> {
return await this.shopyyService.deleteOrder(this.site, id);
}
async getSubscriptions(
params: UnifiedSearchParamsDTO
): Promise<UnifiedPaginationDTO<UnifiedSubscriptionDTO>> {
throw new Error('Shopyy does not support subscriptions.');
}
async getMedia(
params: UnifiedSearchParamsDTO
): Promise<UnifiedPaginationDTO<UnifiedMediaDTO>> {
throw new Error('Shopyy does not support media API.');
}
async getCustomers(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedCustomerDTO>> {
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<UnifiedCustomerDTO> {
const customer = await this.shopyyService.getCustomer(this.site, id);
return this.mapCustomer(customer);
}
async createCustomer(data: Partial<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO> {
const createdCustomer = await this.shopyyService.createCustomer(this.site, data);
return this.mapCustomer(createdCustomer);
}
async updateCustomer(id: string | number, data: Partial<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO> {
const updatedCustomer = await this.shopyyService.updateCustomer(this.site, id, data);
return this.mapCustomer(updatedCustomer);
}
async deleteCustomer(id: string | number): Promise<boolean> {
return await this.shopyyService.deleteCustomer(this.site, id);
}
}

View File

@ -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<UnifiedPaginationDTO<UnifiedProductDTO>> {
const { items, total, totalPages, page, per_page } =
await this.wpService.fetchResourcePaged<any>(
this.site,
'products',
params
);
return {
items: items.map(this.mapProduct),
total,
totalPages,
page,
per_page,
};
}
async getProduct(id: string | number): Promise<UnifiedProductDTO> {
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<UnifiedProductDTO>): Promise<UnifiedProductDTO> {
const res = await this.wpService.createProduct(this.site, data);
return this.mapProduct(res);
}
async updateProduct(id: string | number, data: Partial<UnifiedProductDTO>): Promise<UnifiedProductDTO> {
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<any> {
const res = await this.wpService.updateVariation(this.site, String(productId), String(variationId), data);
return res;
}
async getOrderNotes(orderId: string | number): Promise<any[]> {
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<any> {
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<boolean> {
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<UnifiedPaginationDTO<UnifiedOrderDTO>> {
const { items, total, totalPages, page, per_page } =
await this.wpService.fetchResourcePaged<any>(this.site, 'orders', params);
return {
items: items.map(this.mapOrder),
total,
totalPages,
page,
per_page,
};
}
async getOrder(id: string | number): Promise<UnifiedOrderDTO> {
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<UnifiedOrderDTO>): Promise<UnifiedOrderDTO> {
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<UnifiedOrderDTO>): Promise<boolean> {
return await this.wpService.updateOrder(this.site, String(id), data as any);
}
async deleteOrder(id: string | number): Promise<boolean> {
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<UnifiedPaginationDTO<UnifiedSubscriptionDTO>> {
const { items, total, totalPages, page, per_page } =
await this.wpService.fetchResourcePaged<any>(
this.site,
'subscriptions',
params
);
return {
items: items.map(this.mapSubscription),
total,
totalPages,
page,
per_page,
};
}
async getMedia(
params: UnifiedSearchParamsDTO
): Promise<UnifiedPaginationDTO<UnifiedMediaDTO>> {
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<boolean> {
await this.wpService.deleteMedia(Number(this.site.id), Number(id), true);
return true;
}
async updateMedia(id: string | number, data: any): Promise<any> {
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<UnifiedPaginationDTO<UnifiedCustomerDTO>> {
const { items, total, totalPages, page, per_page } = await this.wpService.fetchResourcePaged<any>(
this.site,
'customers',
params
);
return {
items: items.map((i: any) => this.mapCustomer(i)),
total,
totalPages,
page,
per_page,
};
}
async getCustomer(id: string | number): Promise<UnifiedCustomerDTO> {
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<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO> {
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<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO> {
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<boolean> {
const api = (this.wpService as any).createApi(this.site, 'wc/v3');
await api.delete(`customers/${id}`, { force: true });
return true;
}
}

View File

@ -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.',
// },

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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('同步失败');

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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 || '同步失败');
}

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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

View File

@ -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',

View File

@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTestDataToTemplate1765275715762 implements MigrationInterface {
name = 'AddTestDataToTemplate1765275715762'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`);
}
}

View File

@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSiteDescription1765330208213 implements MigrationInterface {
name = 'AddSiteDescription1765330208213'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`);
}
}

View File

@ -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<Dict> {
private async createOrFindDict(repo: any, dictInfo: { name: string; title: string; titleCn: string; shortName: string }): Promise<Dict> {
// 格式化 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<void> {
private async seedDictItems(repo: any, dict: Dict, items: { name: string; title: string; titleCn: string; shortName: string }[]): Promise<void> {
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 });
}
}
}

View File

@ -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);
}
}

View File

@ -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
*/

258
src/dto/site-api.dto.ts Normal file
View File

@ -0,0 +1,258 @@
import { ApiProperty } from '@midwayjs/swagger';
export class UnifiedPaginationDTO<T> {
@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<UnifiedProductDTO> {
@ApiProperty({ description: '列表数据', type: [UnifiedProductDTO] })
items: UnifiedProductDTO[];
}
export class UnifiedOrderPaginationDTO extends UnifiedPaginationDTO<UnifiedOrderDTO> {
@ApiProperty({ description: '列表数据', type: [UnifiedOrderDTO] })
items: UnifiedOrderDTO[];
}
export class UnifiedCustomerPaginationDTO extends UnifiedPaginationDTO<UnifiedCustomerDTO> {
@ApiProperty({ description: '列表数据', type: [UnifiedCustomerDTO] })
items: UnifiedCustomerDTO[];
}
export class UnifiedSubscriptionPaginationDTO extends UnifiedPaginationDTO<UnifiedSubscriptionDTO> {
@ApiProperty({ description: '列表数据', type: [UnifiedSubscriptionDTO] })
items: UnifiedSubscriptionDTO[];
}
export class UnifiedMediaPaginationDTO extends UnifiedPaginationDTO<UnifiedMediaDTO> {
@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;
}

View File

@ -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 {

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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: '是否可删除',

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,125 @@
// src/interface/platform.interface.ts
/**
*
*
*/
export interface IPlatformService {
/**
*
* @param site
* @returns
*/
getProducts(site: any): Promise<any[]>;
/**
*
* @param site
* @param productId ID
* @returns
*/
getVariations(site: any, productId: number): Promise<any[]>;
/**
*
* @param site
* @param productId ID
* @param variationId ID
* @returns
*/
getVariation(site: any, productId: number, variationId: number): Promise<any>;
/**
*
* @param siteId ID
* @returns
*/
getOrders(siteId: number): Promise<any[]>;
/**
*
* @param siteId ID
* @param orderId ID
* @returns
*/
getOrder(siteId: string, orderId: string): Promise<any>;
/**
*
* @param siteId ID
* @returns
*/
getSubscriptions?(siteId: number): Promise<any[]>;
/**
*
* @param site
* @param data
* @returns
*/
createProduct(site: any, data: any): Promise<any>;
/**
*
* @param site
* @param productId ID
* @param data
* @returns
*/
updateProduct(site: any, productId: string, data: any): Promise<boolean>;
/**
*
* @param site
* @param productId ID
* @param status
* @param stockStatus
* @returns
*/
updateProductStatus(site: any, productId: string, status: string, stockStatus: string): Promise<boolean>;
/**
*
* @param site
* @param productId ID
* @param variationId ID
* @param data
* @returns
*/
updateVariation(site: any, productId: string, variationId: string, data: any): Promise<boolean>;
/**
*
* @param site
* @param orderId ID
* @param data
* @returns
*/
updateOrder(site: any, orderId: string, data: Record<string, any>): Promise<boolean>;
/**
*
* @param site
* @param orderId ID
* @param data
* @returns
*/
createShipment(site: any, orderId: string, data: any): Promise<any>;
/**
*
* @param site
* @param orderId ID
* @param trackingId ID
* @returns
*/
deleteShipment(site: any, orderId: string, trackingId: string): Promise<boolean>;
/**
*
* @param site
* @param data
* @returns
*/
batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise<any>;
}

View File

@ -0,0 +1,81 @@
import {
UnifiedMediaDTO,
UnifiedOrderDTO,
UnifiedPaginationDTO,
UnifiedProductDTO,
UnifiedSearchParamsDTO,
UnifiedSubscriptionDTO,
UnifiedCustomerDTO,
} from '../dto/site-api.dto';
export interface ISiteAdapter {
/**
*
*/
getProducts(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedProductDTO>>;
/**
*
*/
getProduct(id: string | number): Promise<UnifiedProductDTO>;
/**
*
*/
getOrders(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedOrderDTO>>;
/**
*
*/
getOrder(id: string | number): Promise<UnifiedOrderDTO>;
/**
*
*/
getSubscriptions(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedSubscriptionDTO>>;
/**
*
*/
getMedia(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedMediaDTO>>;
/**
*
*/
createProduct(data: Partial<UnifiedProductDTO>): Promise<UnifiedProductDTO>;
/**
*
*/
updateProduct(id: string | number, data: Partial<UnifiedProductDTO>): Promise<UnifiedProductDTO>;
/**
*
*/
updateVariation(productId: string | number, variationId: string | number, data: any): Promise<any>;
/**
*
*/
getOrderNotes(orderId: string | number): Promise<any[]>;
/**
*
*/
createOrderNote(orderId: string | number, data: any): Promise<any>;
/**
*
*/
deleteProduct(id: string | number): Promise<boolean>;
createOrder(data: Partial<UnifiedOrderDTO>): Promise<UnifiedOrderDTO>;
updateOrder(id: string | number, data: Partial<UnifiedOrderDTO>): Promise<boolean>;
deleteOrder(id: string | number): Promise<boolean>;
getCustomers(params: UnifiedSearchParamsDTO): Promise<UnifiedPaginationDTO<UnifiedCustomerDTO>>;
getCustomer(id: string | number): Promise<UnifiedCustomerDTO>;
createCustomer(data: Partial<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO>;
updateCustomer(id: string | number, data: Partial<UnifiedCustomerDTO>): Promise<UnifiedCustomerDTO>;
deleteCustomer(id: string | number): Promise<boolean>;
}

View File

@ -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({

View File

@ -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}`);
}
}
}

View File

@ -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<string, string> = {};
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<any[]> {
// 条件判断:确保产品存在
@ -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<string, { sku?: string; quantity?: number }>();
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 };
}
}

View File

@ -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<string, string> {
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<any> {
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<T>(site: any, endpoint: string, params: Record<string, any> = {}) {
// 映射 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<any> {
// 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<any> {
// 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<any> {
// 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<any> {
// 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<any> {
// 如果传入的是站点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<any> {
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<any> {
// 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<boolean> {
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<boolean> {
// 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<boolean> {
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<string, any>): Promise<boolean> {
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<any> {
// 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<boolean> {
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<any> {
// 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<any> {
// 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<any> {
// 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<boolean> {
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<any> {
// 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<any> {
// ShopYY API: GET /customers
const { items, total, totalPages, page, per_page } =
await this.fetchResourcePaged<any>(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<any> {
// 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<any> {
// 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<any> {
// 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<boolean> {
try {
// ShopYY API: DELETE /customers/{id}
await this.request(site, `customers/${customerId}`, 'DELETE');
return true;
} catch (error) {
console.error('删除ShopYY客户失败:', error);
return false;
}
}
}

View File

@ -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<ISiteAdapter> {
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}`);
}
}

View File

@ -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<Area>;
@InjectEntityModel(StockPoint)
stockPointModel: Repository<StockPoint>;
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<Site> {
// 根据主键获取站点,并使用 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

View File

@ -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}` };
}
}

View File

@ -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<number> {
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;
}
}

View File

@ -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 };
}

View File

@ -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<T>(site: any, resource: string, params: Record<string, any> = {}) {
const api = this.createApi(site, 'wc/v3');
return this.sdkGetPage<T>(api, resource, params);
}
/**
* SDK , totalPages
*/
@ -64,13 +74,9 @@ export class WPService {
* SDK ,
*/
private async sdkGetAll<T>(api: WooCommerceRestApi, resource: string, params: Record<string, any> = {}, maxPages: number = 50): Promise<T[]> {
const result: T[] = [];
for (let page = 1; page <= maxPages; page++) {
const { items, totalPages } = await this.sdkGetPage<T>(api, resource, { ...params, page });
result.push(...items);
if (page >= totalPages) break;
}
return result;
// 直接传入较大的per_page参数一次性获取所有数据
const { items } = await this.sdkGetPage<T>(api, resource, { ...params, per_page: 100 });
return items;
}
/**
@ -157,14 +163,14 @@ export class WPService {
return allData;
}
async getProducts(site: any): Promise<WpProduct[]> {
async getProducts(site: any, page: number = 1, pageSize: number = 100): Promise<any> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<WpProduct>(api, 'products');
return await this.sdkGetPage<WpProduct>(api, 'products', { page, per_page: pageSize });
}
async getVariations(site: any, productId: number): Promise<Variation[]> {
async getVariations(site: any, productId: number, page: number = 1, pageSize: number = 100): Promise<any> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<Variation>(api, `products/${productId}/variations`);
return await this.sdkGetPage<Variation>(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<string, any>;
}
async getOrders(siteId: number): Promise<Record<string, any>[]> {
const site = await this.siteService.get(siteId);
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<Record<string, any>>(api, 'orders');
async getOrders(site: any | number, page: number = 1, pageSize: number = 100): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, 'orders', { page, per_page: pageSize });
}
/**
* WooCommerce Subscriptions
* wc/v1/subscriptions(Subscriptions ),退 wc/v3/subscriptions.
* .
*/
async getSubscriptions(siteId: number): Promise<Record<string, any>[]> {
const site = await this.siteService.get(siteId);
async getSubscriptions(site: any | number, page: number = 1, pageSize: number = 100): Promise<any> {
// 如果传入的是站点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<Record<string, any>>(api, 'subscriptions');
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, 'subscriptions', { page, per_page: pageSize });
}
async getOrderRefund(
@ -217,12 +223,15 @@ export class WPService {
}
async getOrderRefunds(
siteId: string,
orderId: number
): Promise<Record<string, any>[]> {
const site = await this.siteService.get(siteId);
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<Record<string, any>>(api, `orders/${orderId}/refunds`);
site: any | string,
orderId: number,
page: number = 1,
pageSize: number = 100
): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, `orders/${orderId}/refunds`, { page, per_page: pageSize });
}
async getOrderNote(
@ -237,38 +246,42 @@ export class WPService {
}
async getOrderNotes(
siteId: string,
orderId: number
): Promise<Record<string, any>[]> {
const site = await this.siteService.get(siteId);
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<Record<string, any>>(api, `orders/${orderId}/notes`);
site: any | string,
orderId: number,
page: number = 1,
pageSize: number = 100
): Promise<any> {
// 如果传入的是站点ID则获取站点配置
const siteConfig = typeof site === 'string' ? await this.siteService.get(site) : site;
const api = this.createApi(siteConfig, 'wc/v3');
return await this.sdkGetPage<Record<string, any>>(api, `orders/${orderId}/notes`, { page, per_page: pageSize });
}
async updateData<T>(
endpoint: string,
/**
* WooCommerce
* @param site
* @param data
*/
async createProduct(
site: any,
data: Record<string, any>
): Promise<Boolean> {
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<any> {
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<Boolean> {
): Promise<any> {
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<Boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<UpdateVariationDTO>
): Promise<Boolean> {
): Promise<boolean> {
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<string, any>
): Promise<Boolean> {
return await this.updateData(`/wc/v3/orders/${orderId}`, site, data);
): Promise<boolean> {
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<Boolean> {
): Promise<boolean> {
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<any> {
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<any[]> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<any>(api, 'products/categories');
}
/**
*
* @param site
* @param data { create?: [], update?: [], delete?: [] }
*/
async batchProcessCategories(
site: any,
data: { create?: any[]; update?: any[]; delete?: any[] }
): Promise<any> {
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<any[]> {
const api = this.createApi(site, 'wc/v3');
return await this.sdkGetAll<any>(api, 'products/tags');
}
/**
*
* @param site
* @param data { create?: [], update?: [], delete?: [] }
*/
async batchProcessTags(
site: any,
data: { create?: any[]; update?: any[]; delete?: any[] }
): Promise<any> {
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
};
}
/**
*
* @param siteId ID
* @param file
*/
async createMedia(siteId: number, file: any): Promise<any> {
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<any> {
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<any> {
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<any>(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 }[];
}
}

View File

@ -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<WpProduct>;
@InjectEntityModel(Variation)
variationModel: Repository<Variation>;
@InjectEntityModel(Product)
productModel: Repository<Product>;
@InjectEntityModel(ProductSiteSku)
productSiteSkuModel: Repository<ProductSiteSku>;
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<string, Product>();
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);
await this.syncProductAndVariations(site.id, product, variations);
// Determine if we should create or update based on local WpProduct record
const existingWpProduct = await this.wpProductModel.findOne({
where: { siteId, sku: targetSku, on_delete: false }
});
const productData = {
name: product.name,
type: product.type === 'single' ? 'simple' : (product.type === 'bundle' ? 'bundle' : 'simple'),
regular_price: product.price ? String(product.price) : '0',
sale_price: product.promotionPrice ? String(product.promotionPrice) : '',
sku: targetSku,
status: 'publish', // Default to publish
// categories?
};
if (existingWpProduct) {
batchData.update.push({
id: existingWpProduct.externalProductId,
...productData
});
} else {
batchData.create.push(productData);
}
}
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.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));
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 (batchData.create.length === 0 && batchData.update.length === 0) {
this.logToFile('[BatchSync] No actions needed, skipping API call');
return;
}
let result;
try {
result = await this.wpApiService.batchProcessProducts(site, batchData);
this.logToFile('[BatchSync] API Success. Result:', JSON.stringify(result));
} catch (error) {
this.logToFile('[BatchSync] API Error:', error);
throw error;
}
// Process results to update local WpProduct and ProductSiteSku
const processResultItem = async (item: any, sourceList: any[], index: number) => {
const originalSku = sourceList[index]?.sku;
if (item.id) {
this.logToFile(`[BatchSync] Processing success item: ID=${item.id}, SKU=${item.sku}`);
let localProduct = skuToProductMap.get(item.sku);
// Fallback to original SKU if response SKU differs or lookup fails
if (!localProduct && originalSku) {
localProduct = skuToProductMap.get(originalSku);
}
if (localProduct) {
this.logToFile(`[BatchSync] Found local product ID=${localProduct.id} for SKU=${item.sku || originalSku}`);
const code = item.sku || originalSku;
const existingSiteSku = await this.productSiteSkuModel.findOne({
where: { productId: localProduct.id, 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<number, WpProduct[]>();
for (const product of products) {
if (!productsBySite.has(product.siteId)) {
productsBySite.set(product.siteId, []);
}
productsBySite.get(product.siteId).push(product);
}
for (const [siteId, siteProducts] of productsBySite) {
const site = await this.siteService.get(siteId, true);
if (!site) continue;
const batchData = {
create: [],
update: [],
};
for (const product of siteProducts) {
const currentTags = product.tags || [];
// Add new tags, avoiding duplicates by name
const newTags = [...currentTags];
const tagsToAdd = [];
for (const tag of tags) {
if (!newTags.some(t => t.name === tag)) {
const newTagObj = { name: tag, id: 0, slug: '' };
newTags.push(newTagObj);
tagsToAdd.push(newTagObj);
}
}
if (tagsToAdd.length > 0) {
batchData.update.push({
id: product.externalProductId,
tags: newTags.map(t => (t.id ? { id: t.id } : { name: t.name })),
});
// Update local DB optimistically
// Generate slug simply
tagsToAdd.forEach(t => (t.slug = t.name.toLowerCase().replace(/\s+/g, '-')));
product.tags = newTags;
await this.wpProductModel.save(product);
}
}
if (batchData.update.length > 0) {
await this.wpApiService.batchProcessProducts(site, batchData);
}
}
}
async batchUpdateProducts(dto: BatchUpdateProductsDTO) {
const { ids, ...updates } = dto;
if (!ids || ids.length === 0) return;
const products = await this.wpProductModel.find({
where: { id: In(ids) },
});
// Group by siteId
const productsBySite = new Map<number, WpProduct[]>();
for (const product of products) {
if (!productsBySite.has(product.siteId)) {
productsBySite.set(product.siteId, []);
}
productsBySite.get(product.siteId).push(product);
}
for (const [siteId, siteProducts] of productsBySite) {
const site = await this.siteService.get(siteId, true);
if (!site) continue;
// Resolve Categories if needed
let categoryIds: number[] = [];
if (updates.categories && updates.categories.length > 0) {
// 1. Get all existing categories
const allCategories = await this.wpApiService.getCategories(site);
const existingCatMap = new Map(allCategories.map(c => [c.name, c.id]));
// 2. Identify missing categories
const missingCategories = updates.categories.filter(name => !existingCatMap.has(name));
// 3. Create missing categories
if (missingCategories.length > 0) {
const createPayload = missingCategories.map(name => ({ name }));
const createdCatsResult = await this.wpApiService.batchProcessCategories(site, { create: createPayload });
if (createdCatsResult && createdCatsResult.create) {
createdCatsResult.create.forEach(c => {
if (c.id && c.name) existingCatMap.set(c.name, c.id);
});
}
}
// 4. Collect all IDs
categoryIds = updates.categories
.map(name => existingCatMap.get(name))
.filter(id => id !== undefined);
}
// Resolve Tags if needed
let tagIds: number[] = [];
if (updates.tags && updates.tags.length > 0) {
// 1. Get all existing tags
const allTags = await this.wpApiService.getTags(site);
const existingTagMap = new Map(allTags.map(t => [t.name, t.id]));
// 2. Identify missing tags
const missingTags = updates.tags.filter(name => !existingTagMap.has(name));
// 3. Create missing tags
if (missingTags.length > 0) {
const createPayload = missingTags.map(name => ({ name }));
const createdTagsResult = await this.wpApiService.batchProcessTags(site, { create: createPayload });
if (createdTagsResult && createdTagsResult.create) {
createdTagsResult.create.forEach(t => {
if (t.id && t.name) existingTagMap.set(t.name, t.id);
});
}
}
// 4. Collect all IDs
tagIds = updates.tags
.map(name => existingTagMap.get(name))
.filter(id => id !== undefined);
}
const batchData = {
create: [],
update: [],
};
for (const product of siteProducts) {
const updateData: any = {
id: product.externalProductId,
};
if (updates.regular_price) updateData.regular_price = String(updates.regular_price);
if (updates.sale_price) updateData.sale_price = String(updates.sale_price);
if (updates.status) updateData.status = updates.status;
if (categoryIds.length > 0) {
updateData.categories = categoryIds.map(id => ({ id }));
}
if (tagIds.length > 0) {
updateData.tags = tagIds.map(id => ({ id }));
}
batchData.update.push(updateData);
// Optimistic update local DB
if (updates.regular_price) product.regular_price = updates.regular_price;
if (updates.sale_price) product.sale_price = updates.sale_price;
if (updates.status) product.status = updates.status as ProductStatus;
if (updates.categories) product.categories = updates.categories.map(c => ({ name: c, id: 0, slug: '' })); // simple mock
if (updates.tags) product.tags = updates.tags.map(t => ({ name: t, id: 0, slug: '' })); // simple mock
await this.wpProductModel.save(product);
}
if (batchData.update.length > 0) {
await this.wpApiService.batchProcessProducts(site, batchData);
}
}
}
async importProducts(siteId: number, file: any) {
const site = await this.siteService.get(siteId, true);
if (!site) throw new Error('站点不存在');
const parser = fs
.createReadStream(file.data)
.pipe(parse({
columns: true,
skip_empty_lines: true,
trim: true,
bom: true
}));
let batch = [];
const batchSize = 50;
for await (const record of parser) {
batch.push(record);
if (batch.length >= batchSize) {
await this.processImportBatch(siteId, site, batch);
batch = [];
}
}
if (batch.length > 0) {
await this.processImportBatch(siteId, site, batch);
}
}
private async processImportBatch(siteId: number, site: any, chunk: any[]) {
const batchData = {
create: [],
update: [],
};
for (const row of chunk) {
const sku = row['SKU'] || row['sku'];
if (!sku) continue;
const existingProduct = await this.wpProductModel.findOne({
where: { siteId, sku }
});
const productData: any = {
sku: sku,
name: row['Name'] || row['name'],
type: (row['Type'] || row['type'] || 'simple').toLowerCase(),
regular_price: row['Regular price'] || row['regular_price'],
sale_price: row['Sale price'] || row['sale_price'],
short_description: row['Short description'] || row['short_description'] || '',
description: row['Description'] || row['description'] || '',
};
if (productData.regular_price) productData.regular_price = String(productData.regular_price);
if (productData.sale_price) productData.sale_price = String(productData.sale_price);
const imagesStr = row['Images'] || row['images'];
if (imagesStr) {
productData.images = imagesStr.split(',').map(url => ({ src: url.trim() }));
}
if (existingProduct) {
batchData.update.push({
id: existingProduct.externalProductId,
...productData
});
} else {
batchData.create.push(productData);
}
}
if (batchData.create.length > 0 || batchData.update.length > 0) {
try {
const result = await this.wpApiService.batchProcessProducts(site, batchData);
await this.syncBackFromBatchResult(siteId, result);
} catch (e) {
console.error('Batch process error during import:', e);
}
}
}
private async syncBackFromBatchResult(siteId: number, result: any) {
const processResultItem = async (item: any) => {
if (item.id) {
await this.syncProductAndVariations(siteId, item, []);
}
};
if (result.create) {
for (const item of result.create) {
await processResultItem(item);
}
}
if (result.update) {
for (const item of result.update) {
await processResultItem(item);
}
}
}
// 同步产品库存到 Site
async syncProductStockToSite(siteId: number, sku: string) {
const site = await this.siteService.get(siteId, true);
if (!site) throw new Error('站点不存在');
// 获取站点绑定的仓库
if (!site.stockPoints || site.stockPoints.length === 0) {
console.log(`站点 ${siteId} 未绑定任何仓库,跳过库存同步`);
return;
}
// 获取产品在这些仓库的总库存
const stockPointIds = site.stockPoints.map(sp => sp.id);
const stock = await this.stockService.stockModel
.createQueryBuilder('stock')
.select('SUM(stock.quantity)', 'total')
.where('stock.sku = :sku', { sku })
.andWhere('stock.stockPointId IN (:...stockPointIds)', { stockPointIds })
.getRawOne();
const quantity = stock && stock.total ? Number(stock.total) : 0;
const stockStatus = quantity > 0 ? ProductStockStatus.INSTOCK : ProductStockStatus.OUT_OF_STOCK;
// 查找对应的 WpProduct 以获取 externalProductId
const wpProduct = await this.wpProductModel.findOne({ where: { siteId, sku } });
if (wpProduct) {
// 更新 WooCommerce 库存
await this.wpApiService.updateProductStock(site, wpProduct.externalProductId, quantity, stockStatus);
// 更新本地 WpProduct 状态
wpProduct.stock_quantity = quantity;
wpProduct.stockStatus = stockStatus;
await this.wpProductModel.save(wpProduct);
} else {
// 尝试查找变体
const variation = await this.variationModel.findOne({ where: { siteId, sku } });
if (variation) {
await this.wpApiService.updateProductVariationStock(site, variation.externalProductId, variation.externalVariationId, quantity, stockStatus);
// 变体表目前没有 stock_quantity 字段,如果需要可以添加
}
}
}
// 同步一个网站
async syncSite(siteId: number) {
try {
// 通过数据库获取站点并转换为 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<void> {
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,
});
}
}
}
}

View File

@ -0,0 +1,65 @@
export function generateTestDataFromEta(template: string): Record<string, any> {
const data: Record<string, any> = {};
const tagRegex = /<%[\-=]?([\s\S]*?)%>/g;
const itPathRegex = /\bit\.([a-zA-Z0-9_$.\[\]]+)/g;
const setPath = (path: string) => {
const parts: Array<string | number> = [];
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;
}

View File

@ -20,5 +20,5 @@
"inlineSources": true // map ,便 VS Code
},
"exclude": ["*.js", "*.ts", "dist", "node_modules", "test"]
"exclude": ["*.js", "*.ts", "dist", "node_modules", "test", "scripts"]
}