feat(产品服务): 添加产品分组查询功能并优化相关服务

新增产品分组查询接口,支持按指定字段或属性对产品进行分组
优化产品服务中的查询逻辑,修复属性关联查询的字段错误
完善媒体服务接口参数类型定义,增强类型安全性
重构ERP产品信息合并逻辑,使用实体类型替代手动映射
This commit is contained in:
tikkhun 2026-01-21 10:43:14 +08:00
parent b3b7ee4793
commit 71b2c249be
10 changed files with 449 additions and 54 deletions

View File

@ -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<WpMediaGetListParams> {
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<UnifiedPaginationDTO<UnifiedMediaDTO>> {
// 获取媒体列表并映射为统一媒体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,

View File

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

View File

@ -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<ProductWhereFilter>
): Promise<any> {
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) {

View File

@ -50,6 +50,13 @@ export class UnifiedSearchParamsDTO<Where=Record<string, any>> {
required: false,
})
orderBy?: Record<string, 'asc' | 'desc'> | string;
@ApiProperty({
description: '分组字段,例如 "categoryId"',
type: 'string',
required: false,
})
groupBy?: string;
}
/**

View File

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

View File

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

View File

@ -21,7 +21,8 @@ export class CategoryService {
order: {
sort: 'DESC',
createdAt: 'DESC'
}
},
relations: ['attributes', 'attributes.attributeDict']
});
}

View File

@ -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<ProductWhereFilter>): Promise<Record<string, Product[]>> {
// 创建查询构建器
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<string, Product[]> = {};
// 检查是否按属性的字典名称分组
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<number, string> = {};
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,

View File

@ -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<any> {
async enrichSiteProductWithErpInfo(siteId: number, siteProduct: UnifiedProductDTO): Promise<UnifiedProductDTO & { erpProduct?: Product }> {
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<any[]> {
async enrichSiteProductsWithErpInfo(siteId: number, siteProducts: UnifiedProductDTO[]): Promise<(UnifiedProductDTO & { erpProduct?: Product })[]> {
if (!siteProducts || !siteProducts.length) {
return siteProducts;
}

View File

@ -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<string, any> = {}) {
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<string, any>);
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<WpMediaGetListParams> = {}) {
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 };
}
/**
*