diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts index 0849358..e3ffcaa 100644 --- a/src/adapter/woocommerce.adapter.ts +++ b/src/adapter/woocommerce.adapter.ts @@ -28,6 +28,7 @@ import { WooWebhook, WooOrderSearchParams, WooProductSearchParams, + WpMediaGetListParams, } from '../dto/woocommerce.dto'; import { Site } from '../entity/site.entity'; import { WPService } from '../service/wp.service'; @@ -249,13 +250,25 @@ export class WooCommerceAdapter implements ISiteAdapter { date_modified: item.date_modified ?? item.modified, }; } + mapMediaSearchParams(params: UnifiedSearchParamsDTO): Partial { + const page = params.page + const per_page = Number( params.per_page ?? 20); + + return { + ...params.where, + page, + per_page, + // orderby, + // order, + }; + } // 媒体操作方法 async getMedia(params: UnifiedSearchParamsDTO): Promise> { // 获取媒体列表并映射为统一媒体DTO集合 const { items, total, totalPages, page, per_page } = await this.wpService.fetchMediaPaged( this.site, - params + this.mapMediaSearchParams(params) ); return { items: items.map(this.mapPlatformToUnifiedMedia.bind(this)), @@ -633,7 +646,7 @@ export class WooCommerceAdapter implements ISiteAdapter { name: data.name, type: data.type, status: data.status, - sku: data.sku, + sku: data.sku, regular_price: data.regular_price, sale_price: data.sale_price, price: data.price, diff --git a/src/config/config.local.ts b/src/config/config.local.ts index 3894427..bd64204 100644 --- a/src/config/config.local.ts +++ b/src/config/config.local.ts @@ -7,8 +7,12 @@ export default { // dataSource: { // default: { // host: '13.212.62.127', + // port: '3306', // username: 'root', // password: 'Yoone!@.2025', + // database: 'inventory_v2', + // synchronize: true, + // logging: true, // }, // }, // }, @@ -20,7 +24,8 @@ export default { username: 'root', password: 'Yoone!@.2025', database: 'inventory_v2', - synchronize: true + synchronize: true, + logging: true, }, }, }, diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 890a0cb..b67f02f 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -79,6 +79,31 @@ export class ProductController { } } + @ApiOkResponse({ + description: '成功返回分组后的产品列表', + schema: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + $ref: '#/components/schemas/Product', + }, + }, + }, + }) + @Get('/list/grouped') + async getProductListGrouped( + @Query() query: UnifiedSearchParamsDTO + ): Promise { + try { + const data = await this.productService.getProductListGrouped(query); + return successResponse(data); + } catch (error) { + this.logger.error('获取分组产品列表失败', error); + return errorResponse(error?.message || error); + } + } + @ApiOkResponse({ type: ProductRes }) @Post('/') async createProduct(@Body() productData: CreateProductDTO) { diff --git a/src/dto/api.dto.ts b/src/dto/api.dto.ts index 2dd0017..b681336 100644 --- a/src/dto/api.dto.ts +++ b/src/dto/api.dto.ts @@ -50,6 +50,13 @@ export class UnifiedSearchParamsDTO> { required: false, }) orderBy?: Record | string; + + @ApiProperty({ + description: '分组字段,例如 "categoryId"', + type: 'string', + required: false, + }) + groupBy?: string; } /** diff --git a/src/dto/site-api.dto.ts b/src/dto/site-api.dto.ts index 25eec8b..b02cd43 100644 --- a/src/dto/site-api.dto.ts +++ b/src/dto/site-api.dto.ts @@ -3,6 +3,7 @@ import { UnifiedPaginationDTO, } from './api.dto'; import { Dict } from '../entity/dict.entity'; +import { Product } from '../entity/product.entity'; // export class UnifiedOrderWhere{ // [] // } @@ -306,17 +307,7 @@ export class UnifiedProductDTO { type: 'object', required: false, }) - erpProduct?: { - id: number; - sku: string; - name: string; - nameCn?: string; - category?: any; - attributes?: any[]; - components?: any[]; - price: number; - promotionPrice: number; - }; + erpProduct?: Product } export class UnifiedOrderRefundDTO { diff --git a/src/dto/woocommerce.dto.ts b/src/dto/woocommerce.dto.ts index 5077450..f042838 100644 --- a/src/dto/woocommerce.dto.ts +++ b/src/dto/woocommerce.dto.ts @@ -616,6 +616,83 @@ export interface ListParams { parant: string[]; parent_exclude: string[]; } +export interface WpMediaGetListParams { + // 请求范围,决定响应中包含的字段 + // 默认: view + // 可选值: view, embed, edit + context?: 'view' | 'embed' | 'edit'; + + // 当前页码 + // 默认: 1 + page?: number; + + // 每页最大返回数量 + // 默认: 10 + per_page?: number; + + // 搜索字符串,限制结果匹配 + search?: string; + + // ISO8601格式日期,限制发布时间之后的结果 + after?: string; + + // ISO8601格式日期,限制修改时间之后的结果 + modified_after?: string; + + // 作者ID数组,限制结果集为特定作者 + author?: number[]; + + // 作者ID数组,排除特定作者的结果 + author_exclude?: number[]; + + // ISO8601格式日期,限制发布时间之前的结果 + before?: string; + + // ISO8601格式日期,限制修改时间之前的结果 + modified_before?: string; + + // ID数组,排除特定ID的结果 + exclude?: number[]; + + // ID数组,限制结果集为特定ID + include?: number[]; + + // 结果集偏移量 + offset?: number; + + // 排序方向 + // 默认: desc + // 可选值: asc, desc + order?: 'asc' | 'desc'; + + // 排序字段 + // 默认: date + // 可选值: author, date, id, include, modified, parent, relevance, slug, include_slugs, title + orderby?: 'author' | 'date' | 'id' | 'include' | 'modified' | 'parent' | 'relevance' | 'slug' | 'include_slugs' | 'title'; + + // 父ID数组,限制结果集为特定父ID + parent?: number[]; + + // 父ID数组,排除特定父ID的结果 + parent_exclude?: number[]; + + // 搜索的列名数组 + search_columns?: string[]; + + // slug数组,限制结果集为特定slug + slug?: string[]; + + // 状态数组,限制结果集为特定状态 + // 默认: inherit + status?: string[]; + + // 媒体类型,限制结果集为特定媒体类型 + // 可选值: image, video, text, application, audio + media_type?: 'image' | 'video' | 'text' | 'application' | 'audio'; + + // MIME类型,限制结果集为特定MIME类型 + mime_type?: string; +} export enum WooContext { view, edit diff --git a/src/service/category.service.ts b/src/service/category.service.ts index ec0b939..209d725 100644 --- a/src/service/category.service.ts +++ b/src/service/category.service.ts @@ -21,7 +21,8 @@ export class CategoryService { order: { sort: 'DESC', createdAt: 'DESC' - } + }, + relations: ['attributes', 'attributes.attributeDict'] }); } diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 41b168d..554e7bd 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -240,7 +240,7 @@ export class ProductService { const pageSize = query.per_page || 10; // 处理搜索参数 - const name = query.where?.name || query.search || ''; + const name = query.where?.name || ''; // 处理品牌过滤 const brandId = query.where?.brandId; @@ -343,7 +343,7 @@ export class ProductService { .select('product_attributes_dict_item.productId') .from('product_attributes_dict_item', 'product_attributes_dict_item') .innerJoin('dict_item', 'dict_item', 'product_attributes_dict_item.dictItemId = dict_item.id') - .innerJoin('dict', 'dict', 'dict_item.dictId = dict.id') + .innerJoin('dict', 'dict', 'dict_item.dict_id = dict.id') .where('dict.name = :attributeName', { attributeName, }) @@ -468,6 +468,299 @@ export class ProductService { }; } + async getProductListGrouped(query: UnifiedSearchParamsDTO): Promise> { + // 创建查询构建器 + const qb = this.productModel + .createQueryBuilder('product') + .leftJoinAndSelect('product.attributes', 'attribute') + .leftJoinAndSelect('attribute.dict', 'dict') + .leftJoinAndSelect('product.category', 'category'); + + // 验证分组字段 + const groupBy = query.groupBy; + if (!groupBy) { + throw new Error('分组字段不能为空'); + } + + // 处理搜索参数 + const name = query.where?.name || ''; + + // 处理品牌过滤 + const brandId = query.where?.brandId; + const brandIds = query.where?.brandIds; + + // 处理分类过滤 + const categoryId = query.where?.categoryId; + const categoryIds = query.where?.categoryIds; + + // 处理排序参数 + const orderBy = query.orderBy; + + // 模糊搜索 name,支持多个关键词 + const nameFilter = name ? name.split(' ').filter(Boolean) : []; + if (nameFilter.length > 0) { + const nameConditions = nameFilter + .map((word, index) => `product.name LIKE :name${index}`) + .join(' AND '); + const nameParams = nameFilter.reduce( + (params, word, index) => ({ ...params, [`name${index}`]: `%${word}%` }), + {} + ); + qb.where(`(${nameConditions})`, nameParams); + } + + // 处理产品ID过滤 + if (query.where?.id) { + qb.andWhere('product.id = :id', { id: query.where.id }); + } + + // 处理产品ID列表过滤 + if (query.where?.ids && query.where.ids.length > 0) { + qb.andWhere('product.id IN (:...ids)', { ids: query.where.ids }); + } + + // 处理SKU过滤 + if (query.where?.sku) { + qb.andWhere('product.sku LIKE :sku', { sku: `%${query.where.sku}%` }); + } + + // 处理SKU列表过滤 + if (query.where?.skus && query.where.skus.length > 0) { + qb.andWhere('product.sku IN (:...skus)', { skus: query.where.skus }); + } + + // 处理产品中文名称过滤 + if (query.where?.nameCn) { + qb.andWhere('product.nameCn LIKE :nameCn', { nameCn: `%${query.where.nameCn}%` }); + } + + // 处理产品类型过滤 + if (query.where?.type) { + qb.andWhere('product.type = :type', { type: query.where.type }); + } + + // 处理价格范围过滤 + if (query.where?.minPrice !== undefined) { + qb.andWhere('product.price >= :minPrice', { minPrice: query.where.minPrice }); + } + + if (query.where?.maxPrice !== undefined) { + qb.andWhere('product.price <= :maxPrice', { maxPrice: query.where.maxPrice }); + } + + // 处理促销价格范围过滤 + if (query.where?.minPromotionPrice !== undefined) { + qb.andWhere('product.promotionPrice >= :minPromotionPrice', { minPromotionPrice: query.where.minPromotionPrice }); + } + + if (query.where?.maxPromotionPrice !== undefined) { + qb.andWhere('product.promotionPrice <= :maxPromotionPrice', { maxPromotionPrice: query.where.maxPromotionPrice }); + } + + // 处理创建时间范围过滤 + if (query.where?.createdAtStart) { + qb.andWhere('product.createdAt >= :createdAtStart', { createdAtStart: new Date(query.where.createdAtStart) }); + } + + if (query.where?.createdAtEnd) { + qb.andWhere('product.createdAt <= :createdAtEnd', { createdAtEnd: new Date(query.where.createdAtEnd) }); + } + + // 处理更新时间范围过滤 + if (query.where?.updatedAtStart) { + qb.andWhere('product.updatedAt >= :updatedAtStart', { updatedAtStart: new Date(query.where.updatedAtStart) }); + } + + if (query.where?.updatedAtEnd) { + qb.andWhere('product.updatedAt <= :updatedAtEnd', { updatedAtEnd: new Date(query.where.updatedAtEnd) }); + } + + // 处理属性过滤 + const attributeFilters = query.where?.attributes || {}; + Object.entries(attributeFilters).forEach(([attributeName, value], index) => { + if (value === 'hasValue') { + // 如果值为'hasValue',则过滤出具有该属性的产品 + qb.andWhere(qb => { + const subQuery = qb + .subQuery() + .select('product_attributes_dict_item.productId') + .from('product_attributes_dict_item', 'product_attributes_dict_item') + .innerJoin('dict_item', 'dict_item', 'product_attributes_dict_item.dictItemId = dict_item.id') + .innerJoin('dict', 'dict', 'dict_item.dict_id = dict.id') + .where('dict.name = :attributeName', { + attributeName, + }) + .getQuery(); + return 'product.id IN ' + subQuery; + }); + } else if (typeof value === 'number' || !isNaN(Number(value))) { + // 如果值是数字,则过滤出该属性等于该值的产品 + const attributeValueId = Number(value); + qb.andWhere(qb => { + const subQuery = qb + .subQuery() + .select('product_attributes_dict_item.productId') + .from('product_attributes_dict_item', 'product_attributes_dict_item') + .where('product_attributes_dict_item.dictItemId = :attributeValueId', { + attributeValueId, + }) + .getQuery(); + return 'product.id IN ' + subQuery; + }); + } + }); + + // 品牌过滤(向后兼容) + if (brandId) { + qb.andWhere(qb => { + const subQuery = qb + .subQuery() + .select('product_attributes_dict_item.productId') + .from('product_attributes_dict_item', 'product_attributes_dict_item') + .where('product_attributes_dict_item.dictItemId = :brandId', { + brandId, + }) + .getQuery(); + return 'product.id IN ' + subQuery; + }); + } + + // 处理品牌ID列表过滤 + if (brandIds && brandIds.length > 0) { + qb.andWhere(qb => { + const subQuery = qb + .subQuery() + .select('product_attributes_dict_item.productId') + .from('product_attributes_dict_item', 'product_attributes_dict_item') + .where('product_attributes_dict_item.dictItemId IN (:...brandIds)', { + brandIds, + }) + .getQuery(); + return 'product.id IN ' + subQuery; + }); + } + + // 分类过滤(向后兼容) + if (categoryId) { + qb.andWhere('product.categoryId = :categoryId', { categoryId }); + } + + // 处理分类ID列表过滤 + if (categoryIds && categoryIds.length > 0) { + qb.andWhere('product.categoryId IN (:...categoryIds)', { categoryIds }); + } + + // 处理排序(支持新旧两种格式) + if (orderBy) { + if (typeof orderBy === 'string') { + // 如果orderBy是字符串,尝试解析JSON + try { + const orderByObj = JSON.parse(orderBy); + Object.keys(orderByObj).forEach(key => { + const order = orderByObj[key].toUpperCase(); + const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name']; + if (allowedSortFields.includes(key)) { + qb.addOrderBy(`product.${key}`, order as 'ASC' | 'DESC'); + } + }); + } catch (e) { + // 解析失败,使用默认排序 + qb.orderBy('product.createdAt', 'DESC'); + } + } else if (typeof orderBy === 'object') { + // 如果orderBy是对象,直接使用 + Object.keys(orderBy).forEach(key => { + const order = orderBy[key].toUpperCase(); + const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name']; + if (allowedSortFields.includes(key)) { + qb.addOrderBy(`product.${key}`, order as 'ASC' | 'DESC'); + } + }); + } + } else { + qb.orderBy('product.createdAt', 'DESC'); + } + + // 执行查询 + const items = await qb.getMany(); + + // 根据类型填充组成信息 + for (const product of items) { + if (product.type === 'single') { + // 单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成 + const component = new ProductStockComponent(); + component.productId = product.id; + component.sku = product.sku; + component.quantity = 1; + product.components = [component]; + } else { + // 混装商品返回持久化的 SKU 组成 + product.components = await this.productStockComponentModel.find({ + where: { productId: product.id }, + }); + } + } + + // 按指定字段分组 + const groupedResult: Record = {}; + + // 检查是否按属性的字典名称分组 + const isAttributeGrouping = await this.dictModel.findOne({ where: { name: groupBy } }); + + if (isAttributeGrouping) { + // 使用原生SQL查询获取每个产品对应的分组属性值 + const attributeGroupQuery = ` + SELECT product.id as productId, dict_item.id as attributeId, dict_item.name as attributeName, dict_item.title as attributeTitle + FROM product + INNER JOIN product_attributes_dict_item ON product.id = product_attributes_dict_item.productId + INNER JOIN dict_item ON product_attributes_dict_item.dictItemId = dict_item.id + INNER JOIN dict ON dict_item.dict_id = dict.id + WHERE dict.name = ? + `; + + const attributeGroupResults = await this.productModel.query(attributeGroupQuery, [groupBy]); + + // 创建产品ID到分组值的映射 + const productGroupMap: Record = {}; + attributeGroupResults.forEach((result: any) => { + productGroupMap[result.productId] = result.attributeName; + }); + + items.forEach(product => { + // 获取分组值 + const groupValue = productGroupMap[product.id] || 'unknown'; + // 转换为字符串作为键 + const groupKey = String(groupValue); + + // 初始化分组 + if (!groupedResult[groupKey]) { + groupedResult[groupKey] = []; + } + + // 添加产品到分组 + groupedResult[groupKey].push(product); + }); + } else { + // 按产品自身字段分组 + items.forEach(product => { + // 获取分组值 + const groupValue = product[groupBy as keyof Product]; + // 转换为字符串作为键 + const groupKey = String(groupValue); + + // 初始化分组 + if (!groupedResult[groupKey]) { + groupedResult[groupKey] = []; + } + + // 添加产品到分组 + groupedResult[groupKey].push(product); + }); + } + + return groupedResult; + } + async getOrCreateAttribute( dictName: string, itemTitle: string, diff --git a/src/service/site-api.service.ts b/src/service/site-api.service.ts index 22b17e4..3af2439 100644 --- a/src/service/site-api.service.ts +++ b/src/service/site-api.service.ts @@ -7,6 +7,7 @@ import { SiteService } from './site.service'; import { WPService } from './wp.service'; import { ProductService } from './product.service'; import { UnifiedProductDTO } from '../dto/site-api.dto'; +import { Product } from '../entity/product.entity'; @Provide() export class SiteApiService { @@ -52,7 +53,7 @@ export class SiteApiService { * @param siteProduct 站点商品信息 * @returns 包含ERP产品信息的站点商品 */ - async enrichSiteProductWithErpInfo(siteId: number, siteProduct: any): Promise { + async enrichSiteProductWithErpInfo(siteId: number, siteProduct: UnifiedProductDTO): Promise { if (!siteProduct || !siteProduct.sku) { return siteProduct; } @@ -64,18 +65,7 @@ export class SiteApiService { // 将ERP产品信息合并到站点商品中 return { ...siteProduct, - erpProduct: { - id: erpProduct.id, - sku: erpProduct.sku, - name: erpProduct.name, - nameCn: erpProduct.nameCn, - category: erpProduct.category, - attributes: erpProduct.attributes, - components: erpProduct.components, - price: erpProduct.price, - promotionPrice: erpProduct.promotionPrice, - // 可以根据需要添加更多ERP产品字段 - } + erpProduct, }; } catch (error) { // 如果找不到对应的ERP产品,返回原始站点商品 @@ -90,7 +80,7 @@ export class SiteApiService { * @param siteProducts 站点商品列表 * @returns 包含ERP产品信息的站点商品列表 */ - async enrichSiteProductsWithErpInfo(siteId: number, siteProducts: any[]): Promise { + async enrichSiteProductsWithErpInfo(siteId: number, siteProducts: UnifiedProductDTO[]): Promise<(UnifiedProductDTO & { erpProduct?: Product })[]> { if (!siteProducts || !siteProducts.length) { return siteProducts; } diff --git a/src/service/wp.service.ts b/src/service/wp.service.ts index a4d66e5..3500bcb 100644 --- a/src/service/wp.service.ts +++ b/src/service/wp.service.ts @@ -1,6 +1,8 @@ /** - * + * wp 接口参考: * https://developer.wordpress.org/rest-api/reference/media/ + * woocommerce: + * */ import { Inject, Provide } from '@midwayjs/core'; import axios, { AxiosRequestConfig } from 'axios'; @@ -10,7 +12,7 @@ import { IPlatformService } from '../interface/platform.interface'; import { BatchOperationDTO, BatchOperationResultDTO } from '../dto/batch.dto'; import * as FormData from 'form-data'; import * as fs from 'fs'; -import { WooProduct, WooVariation } from '../dto/woocommerce.dto'; +import { WooProduct, WooVariation, WpMediaGetListParams } from '../dto/woocommerce.dto'; const MAX_PAGE_SIZE = 100; @Provide() export class WPService implements IPlatformService { @@ -1044,20 +1046,7 @@ export class WPService implements IPlatformService { }; } - public async fetchMediaPaged(site: any, params: Record = {}) { - const page = Number(params.page ?? 1); - const per_page = Number( params.per_page ?? 20); - const where = params.where && typeof params.where === 'object' ? params.where : {}; - let orderby: string | undefined = params.orderby; - let order: 'asc' | 'desc' | undefined = params.orderDir as any; - if (!orderby && params.order && typeof params.order === 'object') { - const entries = Object.entries(params.order as Record); - if (entries.length > 0) { - const [field, dir] = entries[0]; - orderby = field; - order = String(dir).toLowerCase() === 'desc' ? 'desc' : 'asc'; - } - } +public async fetchMediaPaged(site: any, params: Partial = {}) { const apiUrl = site.apiUrl; const { consumerKey, consumerSecret } = site as any; const endpoint = 'wp/v2/media'; @@ -1066,17 +1055,21 @@ export class WPService implements IPlatformService { const response = await axios.get(url, { headers: { Authorization: `Basic ${auth}` }, params: { - ...where, - ...(params.search ? { search: params.search } : {}), - ...(orderby ? { orderby } : {}), - ...(order ? { order } : {}), - page, - per_page + ...params, + page: params.page ?? 1, + per_page: params.per_page ?? 20, } }); + // 检查是否有错误信息 + if(response?.data?.message){ + throw new Error(`获取${apiUrl}条媒体文件失败,原因为${response.data.message}`) + } + if(!Array.isArray(response.data)) { + throw new Error(`获取${apiUrl}条媒体文件失败,原因为返回数据不是数组`); + } const total = Number(response.headers['x-wp-total'] || 0); const totalPages = Number(response.headers['x-wp-totalpages'] || 0); - return { items: response.data, total, totalPages, page, per_page, page_size: per_page }; + return { items: response.data, total, totalPages, page:params.page ?? 1, per_page: params.per_page ?? 20, page_size: params.per_page ?? 20 }; } /** * 上传媒体文件