cangku/backend/src/services/aiImageSearch.js

820 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const axios = require('axios');
const crypto = require('crypto');
const database = require('../config/database');
const planningDatabase = require('./planningDatabase');
const { buildImageHash, buildNormalizedTags } = require('./aiImageFeatureLibrary');
const { getDefaultS3Config, uploadBase64ImageToDefaultS3 } = require('./objectStorage');
const { ensurePlanningAiImageSchema } = require('./planningSupportSchema');
const ARK_BASE_URL = process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
const VISION_MODEL = process.env.VISION_MODEL || 'doubao-1-5-vision-pro-32k-250115';
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'doubao-embedding-vision-250615';
const DEFAULT_THRESHOLD = 0.5;
const DEFAULT_LIMIT = 20;
const VISION_PROMPT = [
'请识别这张商品图,并严格返回 JSON 对象。',
'强约束:',
'1. 只返回以下 6 个字段:category, space, scene, style, color, material',
'2. 不要返回任何其他字段',
'3. 所有字段值必须使用中文',
'4. 不要返回英文',
'5. 不要解释,不要补充说明,不要输出 markdown',
'6. 如无法判断,返回“其他”',
'字段要求:',
'- category:商品类目,使用中文短语',
'- space:只能从以下值中选择一个:客厅、卧室、餐厅、书房、办公室、阳台、户外、其他',
'- scene:只能从以下值中选择一个:家用、办公、商用、酒店民宿、其他',
'- style:款式风格,使用中文短语',
'- color:主要颜色,使用中文短语',
'- material:主要材质,使用中文短语',
].join('\n');
function getArkApiKey() {
const apiKey = (process.env.ARK_API_KEY || '').trim();
if (!apiKey) {
const error = new Error('未配置 ARK_API_KEY,无法执行 AI 图片检索');
error.statusCode = 500;
throw error;
}
return apiKey;
}
function safeJsonParse(value, fallback = null) {
if (value === null || value === undefined || value === '') {
return fallback;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
function toNullableText(value, maxLength = 1000) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
if (!text) {
return null;
}
return text.slice(0, maxLength);
}
function normalizeSearchResultItem(item = {}) {
const similarity = Number(item.similarity);
const finalScore = Number(item.finalScore);
const similarityPercent = Number.isFinite(Number(item.similarityPercent))
? Number(item.similarityPercent)
: (Number.isFinite(similarity) ? Number((similarity * 100).toFixed(2)) : 0);
const finalScorePercent = Number.isFinite(Number(item.finalScorePercent))
? Number(item.finalScorePercent)
: (Number.isFinite(finalScore) ? Number((finalScore * 100).toFixed(2)) : similarityPercent);
const originalSimilarityPercent = Number.isFinite(Number(item.originalSimilarityPercent))
? Number(item.originalSimilarityPercent)
: similarityPercent;
const tagBonusPercent = Number.isFinite(Number(item.tagBonusPercent))
? Number(item.tagBonusPercent)
: Number((finalScorePercent - originalSimilarityPercent).toFixed(2));
return {
...item,
similarity: Number.isFinite(similarity) ? similarity : originalSimilarityPercent / 100,
finalScore: Number.isFinite(finalScore) ? finalScore : finalScorePercent / 100,
similarityPercent,
originalSimilarityPercent,
tagBonusPercent,
finalScorePercent,
};
}
function cosineSimilarity(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) {
return -1;
}
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i += 1) {
const x = Number(a[i]) || 0;
const y = Number(b[i]) || 0;
dot += x * y;
normA += x * x;
normB += y * y;
}
if (!normA || !normB) {
return -1;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
function calcTagBonus(queryTags, candidate) {
let bonus = 0;
if (candidate.category_tag && candidate.category_tag === queryTags.category) {
bonus += 0.03;
}
if (candidate.color_tag && candidate.color_tag === queryTags.color) {
bonus += 0.02;
}
if (candidate.material_tag && candidate.material_tag === queryTags.material) {
bonus += 0.015;
}
if (candidate.style_tag && candidate.style_tag === queryTags.style) {
bonus += 0.01;
}
if (candidate.space_tag && candidate.space_tag === queryTags.space) {
bonus += 0.005;
}
return bonus;
}
async function callVision(imageUrl) {
const response = await axios.post(
`${ARK_BASE_URL}/chat/completions`,
{
model: VISION_MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: VISION_PROMPT },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
],
max_tokens: 300,
temperature: 0.1,
response_format: { type: 'json_object' },
},
{
headers: {
Authorization: `Bearer ${getArkApiKey()}`,
'Content-Type': 'application/json',
},
timeout: 120000,
}
);
return {
rawTags: safeJsonParse(response.data?.choices?.[0]?.message?.content, {}) || {},
usage: response.data?.usage || {},
model: response.data?.model || VISION_MODEL,
};
}
async function callEmbedding(imageUrl) {
const response = await axios.post(
`${ARK_BASE_URL}/embeddings/multimodal`,
{
model: EMBEDDING_MODEL,
input: [
{ type: 'text', text: '只关注商品主体外观进行向量化,尽量忽略背景、模特、文字、水印、边框和详情页装饰。相同商品在不同背景下应尽量接近。' },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
{
headers: {
Authorization: `Bearer ${getArkApiKey()}`,
'Content-Type': 'application/json',
},
timeout: 120000,
}
);
const embeddingRow = Array.isArray(response.data?.data) ? response.data.data[0] : response.data?.data;
return {
vector: Array.isArray(embeddingRow?.embedding) ? embeddingRow.embedding : [],
usage: response.data?.usage || {},
model: response.data?.model || EMBEDDING_MODEL,
};
}
async function resolveSearchImageSource({ imageUrl, imageBase64 }) {
const normalizedUrl = toNullableText(imageUrl, 1000);
if (normalizedUrl) {
return {
sourceType: 'url',
imageForModel: normalizedUrl,
persistedImageUrl: normalizedUrl,
imageHash: buildImageHash({ imageUrl: normalizedUrl, sourceSystem: 'pdd', sourceTable: 'new_product_selection', sourceId: normalizedUrl }),
};
}
const normalizedBase64 = toNullableText(imageBase64, 10 * 1024 * 1024);
if (!normalizedBase64 || !normalizedBase64.startsWith('data:image/')) {
const error = new Error('请上传图片,或提供可访问的图片链接');
error.statusCode = 400;
throw error;
}
let persistedImageUrl = null;
try {
const s3Config = await getDefaultS3Config(true);
if (s3Config?.bucket && s3Config?.accessKeyId && s3Config?.secretAccessKey) {
const uploadResult = await uploadBase64ImageToDefaultS3(normalizedBase64, 'pdd-new-product-selection');
persistedImageUrl = uploadResult.url;
}
} catch (error) {
persistedImageUrl = null;
}
return {
sourceType: 'upload',
// 豆包对部分外链图片会返回 UnsupportedImageFormat,本地上传场景统一直接使用原始 data URL 给模型。
imageForModel: normalizedBase64,
persistedImageUrl,
imageHash: crypto.createHash('sha256').update(normalizedBase64).digest('hex'),
};
}
async function getPddNewProductSelectionCategories() {
await ensurePlanningAiImageSchema();
const rows = await planningDatabase.query(
`
SELECT
category_name AS categoryName,
category_tag AS categoryTag,
COUNT(1) AS itemCount
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND category_name IS NOT NULL
AND category_name <> ''
AND category_tag IS NOT NULL
AND category_tag <> ''
GROUP BY category_name, category_tag
ORDER BY COUNT(1) DESC, category_name ASC, category_tag ASC
`
);
const categoryMap = new Map();
for (const row of rows) {
const categoryName = row.categoryName;
const categoryTag = row.categoryTag;
const itemCount = Number(row.itemCount || 0);
if (!categoryMap.has(categoryName)) {
categoryMap.set(categoryName, {
categoryName,
categoryTag,
itemCount,
totalCount: itemCount,
});
continue;
}
const current = categoryMap.get(categoryName);
current.totalCount += itemCount;
if (itemCount > current.itemCount) {
current.categoryTag = categoryTag;
current.itemCount = itemCount;
}
}
return Array.from(categoryMap.values())
.sort((a, b) => b.totalCount - a.totalCount || a.categoryName.localeCompare(b.categoryName, 'zh-CN'))
.map((item) => ({
categoryName: item.categoryName,
categoryTag: item.categoryTag,
itemCount: item.totalCount,
}));
}
async function getPddNewProductSelectionLibraryFilterOptions() {
await ensurePlanningAiImageSchema();
const [ categoryRows, styleRows, colorRows, materialRows ] = await Promise.all([
planningDatabase.query(
`
SELECT category_name AS categoryName, COUNT(1) AS itemCount
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND category_name IS NOT NULL
AND category_name <> ''
GROUP BY category_name
ORDER BY COUNT(1) DESC, category_name ASC
`
),
planningDatabase.query(
`
SELECT style_tag AS styleTag, COUNT(1) AS itemCount
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND style_tag IS NOT NULL
AND style_tag <> ''
GROUP BY style_tag
ORDER BY COUNT(1) DESC, style_tag ASC
`
),
planningDatabase.query(
`
SELECT color_tag AS colorTag, COUNT(1) AS itemCount
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND color_tag IS NOT NULL
AND color_tag <> ''
GROUP BY color_tag
ORDER BY COUNT(1) DESC, color_tag ASC
`
),
planningDatabase.query(
`
SELECT material_tag AS materialTag, COUNT(1) AS itemCount
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND material_tag IS NOT NULL
AND material_tag <> ''
GROUP BY material_tag
ORDER BY COUNT(1) DESC, material_tag ASC
`
),
]);
return {
categoryNames: categoryRows.map((row) => ({
value: row.categoryName,
label: row.categoryName,
itemCount: Number(row.itemCount || 0),
})),
styleTags: styleRows.map((row) => ({
value: row.styleTag,
label: row.styleTag,
itemCount: Number(row.itemCount || 0),
})),
colorTags: colorRows.map((row) => ({
value: row.colorTag,
label: row.colorTag,
itemCount: Number(row.itemCount || 0),
})),
materialTags: materialRows.map((row) => ({
value: row.materialTag,
label: row.materialTag,
itemCount: Number(row.itemCount || 0),
})),
};
}
async function getPddNewProductSelectionLibraryList(params = {}) {
await ensurePlanningAiImageSchema();
const page = Math.max(1, Number(params.page) || 1);
const pageSize = Math.max(1, Math.min(Number(params.pageSize) || 20, 100));
const categoryName = toNullableText(params.categoryName, 200);
const keyword = toNullableText(params.keyword, 200);
const styleTag = toNullableText(params.styleTag, 100);
const colorTag = toNullableText(params.colorTag, 100);
const materialTag = toNullableText(params.materialTag, 100);
const whereParts = [
"biz_type = 'internal'",
"image_status = 'success'",
];
const bindParams = [];
if (categoryName) {
whereParts.push('category_name = ?');
bindParams.push(categoryName);
}
if (styleTag) {
whereParts.push('style_tag = ?');
bindParams.push(styleTag);
}
if (colorTag) {
whereParts.push('color_tag = ?');
bindParams.push(colorTag);
}
if (materialTag) {
whereParts.push('material_tag = ?');
bindParams.push(materialTag);
}
if (keyword) {
whereParts.push('(sku_id LIKE ? OR goods_id LIKE ? OR product_name LIKE ? OR category_tag LIKE ?)');
bindParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const totalRows = await planningDatabase.query(
`
SELECT COUNT(1) AS total
FROM ai_image_feature_library
${whereSql}
`,
bindParams
);
const total = Number(totalRows[0]?.total || 0);
const rows = total > 0
? await planningDatabase.query(
`
SELECT
id,
sku_id AS skuId,
goods_id AS goodsId,
product_name AS productName,
category_name AS categoryName,
category_tag AS categoryTag,
space_tag AS spaceTag,
scene_tag AS sceneTag,
style_tag AS styleTag,
color_tag AS colorTag,
material_tag AS materialTag,
image_url AS imageUrl,
query_status
FROM (
SELECT
id,
sku_id,
goods_id,
product_name,
category_name,
category_tag,
space_tag,
scene_tag,
style_tag,
color_tag,
material_tag,
image_url,
image_status AS query_status
FROM ai_image_feature_library
${whereSql}
ORDER BY updated_at DESC, id DESC
LIMIT ? OFFSET ?
) t
`,
[ ...bindParams, pageSize, (page - 1) * pageSize ]
)
: [];
return {
page,
pageSize,
total,
rows: rows.map((row) => ({
...row,
status: row.query_status,
})),
};
}
async function getPddNewProductSelectionLogs(params = {}) {
await ensurePlanningAiImageSchema();
const page = Math.max(1, Number(params.page) || 1);
const pageSize = Math.max(1, Math.min(100, Number(params.pageSize) || Number(params.limit) || 10));
const offset = (page - 1) * pageSize;
const operatorKeyword = toNullableText(params.operatorKeyword, 100);
const sourceType = toNullableText(params.sourceType, 20);
const selectedCategoryTag = toNullableText(params.selectedCategoryTag, 100);
const status = toNullableText(params.status, 20);
const keyword = toNullableText(params.keyword, 200);
const whereParts = [ '1 = 1' ];
const queryParams = [];
if (operatorKeyword) {
whereParts.push('(created_by_name LIKE ? OR created_by_username LIKE ?)');
queryParams.push(`%${operatorKeyword}%`, `%${operatorKeyword}%`);
}
if (sourceType) {
whereParts.push('source_type = ?');
queryParams.push(sourceType);
}
if (selectedCategoryTag) {
whereParts.push('selected_category_tag = ?');
queryParams.push(selectedCategoryTag);
}
if (status) {
whereParts.push('status = ?');
queryParams.push(status);
}
if (keyword) {
whereParts.push('(biz_no LIKE ? OR source_input LIKE ?)');
queryParams.push(`%${keyword}%`, `%${keyword}%`);
}
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
const totalRows = await planningDatabase.query(
`
SELECT COUNT(*) AS total
FROM ai_image_search_logs
${whereSql}
`,
queryParams
);
const rows = await planningDatabase.query(
`
SELECT
id,
biz_no AS bizNo,
source_type AS sourceType,
source_input AS sourceInput,
query_image_url AS queryImageUrl,
created_by_name AS createdByName,
created_by_username AS createdByUsername,
selected_category_tag AS selectedCategoryTag,
result_count AS resultCount,
candidate_count AS candidateCount,
similarity_threshold AS similarityThreshold,
status,
error_message AS errorMessage,
created_at AS createdAt
FROM ai_image_search_logs
${whereSql}
ORDER BY id DESC
LIMIT ?
OFFSET ?
`,
[ ...queryParams, pageSize, offset ]
);
return {
page,
pageSize,
total: Number(totalRows?.[0]?.total || 0),
rows: rows.map((row) => ({
id: row.id,
bizNo: row.bizNo,
sourceType: row.sourceType,
sourceInput: row.sourceInput,
queryImageUrl: row.queryImageUrl,
createdByName: row.createdByName,
createdByUsername: row.createdByUsername,
selectedCategoryTag: row.selectedCategoryTag,
resultCount: Number(row.resultCount || 0),
candidateCount: Number(row.candidateCount || 0),
similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD),
status: row.status,
errorMessage: row.errorMessage,
createdAt: row.createdAt,
})),
};
}
async function getPddNewProductSelectionLogDetail(logId) {
await ensurePlanningAiImageSchema();
const rows = await planningDatabase.query(
`
SELECT
id,
biz_no AS bizNo,
source_type AS sourceType,
source_input AS sourceInput,
query_image_url AS queryImageUrl,
created_by_name AS createdByName,
selected_category_tag AS selectedCategoryTag,
raw_tags_json AS rawTagsJson,
normalized_tags_json AS normalizedTagsJson,
result_count AS resultCount,
candidate_count AS candidateCount,
similarity_threshold AS similarityThreshold,
result_snapshot_json AS resultSnapshotJson,
embedding_dimension AS embeddingDimension,
status,
error_message AS errorMessage,
response_ms AS responseMs,
created_at AS createdAt
FROM ai_image_search_logs
WHERE id = ?
LIMIT 1
`,
[ logId ]
);
const row = rows[0];
if (!row) {
const error = new Error('未找到该条操作记录');
error.statusCode = 404;
throw error;
}
return {
id: row.id,
bizNo: row.bizNo,
sourceType: row.sourceType,
sourceInput: row.sourceInput,
queryImageUrl: row.queryImageUrl,
createdByName: row.createdByName,
selectedCategoryTag: row.selectedCategoryTag,
rawTags: safeJsonParse(row.rawTagsJson, {}),
normalizedTags: safeJsonParse(row.normalizedTagsJson, {}),
resultCount: Number(row.resultCount || 0),
candidateCount: Number(row.candidateCount || 0),
similarityThreshold: Number(row.similarityThreshold || DEFAULT_THRESHOLD),
results: (safeJsonParse(row.resultSnapshotJson, []) || []).map((item) => normalizeSearchResultItem(item)),
embeddingDimension: Number(row.embeddingDimension || 0),
status: row.status,
errorMessage: row.errorMessage,
responseMs: Number(row.responseMs || 0),
createdAt: row.createdAt,
};
}
async function resolveOperator(userId) {
const rows = await database.local.query(
`
SELECT
u.id,
u.username,
u.name,
u.department_id AS departmentId,
d.name AS departmentName
FROM users u
LEFT JOIN departments d ON u.department_id = d.id
WHERE u.id = ?
LIMIT 1
`,
[ userId ]
);
return rows[0] || null;
}
async function insertSearchLog(payload) {
await ensurePlanningAiImageSchema();
const result = await planningDatabase.insert('ai_image_search_logs', payload);
return result.insertId;
}
async function updateSearchLog(id, payload) {
await ensurePlanningAiImageSchema();
await planningDatabase.update('ai_image_search_logs', id, payload);
}
async function searchPddNewProductSelection({ userId, imageUrl, imageBase64, selectedCategoryTag, similarityThreshold = DEFAULT_THRESHOLD, limit = DEFAULT_LIMIT, sourceInput }) {
const operator = await resolveOperator(userId);
if (!operator) {
const error = new Error('当前用户不存在');
error.statusCode = 401;
throw error;
}
const categoryTag = toNullableText(selectedCategoryTag, 100);
if (!categoryTag) {
const error = new Error('请选择类目');
error.statusCode = 400;
throw error;
}
const bizNo = crypto.randomUUID();
const startedAt = Date.now();
const resolvedSource = await resolveSearchImageSource({ imageUrl, imageBase64 });
const logId = await insertSearchLog({
biz_no: bizNo,
source_type: resolvedSource.sourceType,
source_input: toNullableText(sourceInput || imageUrl || (resolvedSource.sourceType === 'upload' ? '本地上传图片' : ''), 1000),
query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000),
query_image_hash: resolvedSource.imageHash,
selected_category_tag: categoryTag,
similarity_threshold: Number(similarityThreshold || DEFAULT_THRESHOLD),
status: 'processing',
created_by_user_id: operator.id,
created_by_username: operator.username,
created_by_name: operator.name,
created_department_id: operator.departmentId || null,
created_department_name: operator.departmentName || null,
});
try {
const [ vision, embedding ] = await Promise.all([
callVision(resolvedSource.imageForModel),
callEmbedding(resolvedSource.imageForModel),
]);
const normalizedTags = buildNormalizedTags(vision.rawTags, {
category: categoryTag,
categoryName: categoryTag,
});
normalizedTags.category = categoryTag;
const candidates = await planningDatabase.query(
`
SELECT
id,
sku_id,
goods_id,
product_name,
image_url,
category_name,
category_tag,
space_tag,
scene_tag,
style_tag,
color_tag,
material_tag,
raw_tags_json,
normalized_tags_json,
embedding_vector_json
FROM ai_image_feature_library
WHERE biz_type = 'internal'
AND image_status = 'success'
AND category_tag = ?
AND embedding_vector_json IS NOT NULL
AND embedding_vector_json <> ''
`,
[ categoryTag ]
);
const threshold = Number(similarityThreshold || DEFAULT_THRESHOLD);
const limitedCount = Math.max(1, Math.min(Number(limit || DEFAULT_LIMIT), 50));
const ranked = candidates
.map((item) => {
const vector = safeJsonParse(item.embedding_vector_json, []);
const similarity = cosineSimilarity(embedding.vector, vector);
if (similarity < 0) {
return null;
}
const tagBonus = calcTagBonus(normalizedTags, item);
const finalScore = similarity + tagBonus;
return {
skuId: item.sku_id,
goodsId: item.goods_id,
productName: item.product_name,
imageUrl: item.image_url,
categoryName: item.category_name,
categoryTag: item.category_tag,
spaceTag: item.space_tag,
sceneTag: item.scene_tag,
styleTag: item.style_tag,
colorTag: item.color_tag,
materialTag: item.material_tag,
rawTags: safeJsonParse(item.raw_tags_json, {}),
normalizedTags: safeJsonParse(item.normalized_tags_json, {}),
similarity,
similarityPercent: Number((similarity * 100).toFixed(2)),
originalSimilarityPercent: Number((similarity * 100).toFixed(2)),
tagBonus,
tagBonusPercent: Number((tagBonus * 100).toFixed(2)),
finalScore,
finalScorePercent: Number((finalScore * 100).toFixed(2)),
};
})
.filter(Boolean)
.filter((item) => item.finalScore >= threshold)
.sort((a, b) => b.finalScore - a.finalScore)
.slice(0, limitedCount)
.map((item, index) => normalizeSearchResultItem({
rank: index + 1,
...item,
}));
const elapsedMs = Date.now() - startedAt;
await updateSearchLog(logId, {
query_image_url: toNullableText(resolvedSource.persistedImageUrl || imageUrl, 1000),
raw_tags_json: JSON.stringify(vision.rawTags),
normalized_tags_json: JSON.stringify(normalizedTags),
embedding_dimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0,
candidate_count: candidates.length,
result_count: ranked.length,
result_snapshot_json: JSON.stringify(ranked),
status: 'success',
response_ms: elapsedMs,
error_message: null,
});
return {
bizNo,
logId,
selectedCategoryTag: categoryTag,
similarityThreshold: threshold,
candidateCount: candidates.length,
resultCount: ranked.length,
elapsedMs,
queryImageUrl: resolvedSource.persistedImageUrl || imageUrl || null,
rawTags: vision.rawTags,
normalizedTags,
embeddingDimension: Array.isArray(embedding.vector) ? embedding.vector.length : 0,
results: ranked,
};
} catch (error) {
await updateSearchLog(logId, {
status: 'failed',
response_ms: Date.now() - startedAt,
error_message: toNullableText(error.response?.data ? JSON.stringify(error.response.data) : error.message, 2000),
});
throw error;
}
}
module.exports = {
getPddNewProductSelectionCategories,
getPddNewProductSelectionLibraryFilterOptions,
getPddNewProductSelectionLibraryList,
getPddNewProductSelectionLogDetail,
getPddNewProductSelectionLogs,
searchPddNewProductSelection,
};