'use strict'; const axios = require('axios'); const crypto = require('crypto'); const planningDatabase = require('./planningDatabase'); const { getPlanningDemandKeywordEvidence } = require('./planningDemandWordAnalysisService'); const { fetchEmbedding: fetchTaobaoProductEmbedding } = require('./taobaoProductEmbeddingSync'); const { uploadBufferToDefaultS3 } = require('./objectStorage'); const { generateProductTitleCandidates, generateSkuNameCandidates, getSelectionFissionAiConfig, } = require('./selectionFissionAiSettings'); const { generateSelectionImage } = require('./selectionImageGenerationService'); const { getSimilarProductsRuntimeConfig } = require('./similarProductsSettings'); const { buildAlibaba1688SignedHeaders, getAlibaba1688GatewayError, getAlibaba1688ImageSearchRuntimeConfig, } = require('./alibaba1688ImageSearchSettings'); const { ProductCodePublicApiError, getProductCodeMeta, } = require('./productCodePublicApiClient'); const { getEncodingByProductId } = require('./alibaba1688ProductEncodingRegistry'); const { buildAlibaba1688SkuKey, buildAlibaba1688SkuFingerprint, } = require('./alibaba1688SkuIdentity'); const { buildDraftFingerprint, getPddDraftMetadata, getSourceDimensions, publishPddGoodsDraft, } = require('./pddOpenPlatformDraftPublisher'); const { listPddPublishingShops, resolvePddPublishingShop, } = require('./pddPublishingShopService'); const { preparePddDraftAttributes } = require('./pddDraftAttributePreparationService'); const { listOnlineDesktopUsers } = require('./desktopPresenceService'); const PLAN_TABLE = 'planning_selection_fission_plans'; const ITEM_TABLE = 'planning_selection_fission_plan_items'; const PRICING_TABLE = 'planning_selection_fission_pricing_rows'; const ACTIVITY_PRICING_TABLE = 'planning_selection_fission_activity_pricing_rows'; const LISTING_RESULT_TABLE = 'planning_selection_fission_listing_results'; const CALLBACK_HISTORY_TABLE = 'planning_selection_fission_callback_history'; const REVISION_HISTORY_TABLE = 'planning_selection_fission_revision_history'; const USER_SHOP_TABLE = 'planning_selection_fission_user_shops'; const COMPANY_SNAPSHOT_TABLE = 'planning_may_product_snapshot_di'; const MARKET_IMAGE_INDEX_TABLE = 'image_search_market_item_index_records'; const MARKET_RANK_TABLE = 'planning_market_rank_records'; const imageAspectRatioCache = new Map(); const imageGenerationReferenceCache = new Map(); const pddAttributePreparationJobs = new Map(); const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; const SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS = Math.max( Number(process.env.SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS) || 20000, 5000 ); const DEFAULT_SIMILAR_TOP_K = 100; const COMPANY_SPU_CANDIDATE_LIMIT = 100; const COMPANY_SIMILAR_DEFAULT_SCORE_THRESHOLD = 0.6; const DEFAULT_EXTERNAL_TIMEOUT_MS = 45000; const DEFAULT_SIMILAR_PRODUCTS_RETRY_ATTEMPTS = 2; const MAX_SIMILAR_PRODUCTS_RETRY_ATTEMPTS = 3; const SIMILAR_PRODUCTS_RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); const SIMILAR_PRODUCTS_RETRYABLE_CODES = new Set([ 'ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', ]); const DEFAULT_ALIBABA_1688_RETRY_ATTEMPTS = 3; const MAX_ALIBABA_1688_RETRY_ATTEMPTS = 3; const ALIBABA_1688_RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); const ALIBABA_1688_RETRYABLE_CODES = new Set([ 'ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', ]); const MAX_SKU_QUERY_LIMIT = 300; const MAX_IMAGE_GENERATION_ROWS = 40; const SKU_IMAGE_GENERATION_BATCH_SIZE = 5; const MAIN_IMAGE_GENERATION_CONCURRENCY = 5; const DEFAULT_CLAIM_LEASE_SECONDS = 24 * 60 * 60; const MAX_CLAIM_LEASE_SECONDS = 24 * 60 * 60; const MAX_SHOP_SYNC_ROWS = 200; const MAX_SPUS_PER_MARKET_ITEM = 10; const ALIBABA_1688_MARKET_REFERENCE_COUNT = 10; const ALIBABA_1688_MARKET_REFERENCE_RECOMMEND_LIMIT = 50; const ALIBABA_1688_MAIN_IMAGE_MODES = new Set([ 'source_original', 'clean_only', 'market_composite', ]); const SKU_IMAGE_MODES = new Set([ 'source_original', 'clean_only', 'market_composite', ]); const MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT = Math.min( 2000, Math.max(Number(process.env.SELECTION_MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT) || 500, 100) ); const WORKFLOW_STEP_DEFINITIONS = [ { step: 1, key: 'market-image', label: '市场视觉资产采集', startProgress: 0, endProgress: 12 }, { step: 2, key: 'select-spu', label: '企业同款智能匹配', startProgress: 12, endProgress: 30, requiresUser: true }, { step: 8, key: 'pricing', label: '全渠道价格策略编排', startProgress: 30, endProgress: 42, requiresUser: true }, { step: 3, key: 'main-image', label: '主视觉商品智能重构', startProgress: 42, endProgress: 58, requiresUser: true }, { step: 4, key: 'sku-images', label: 'SKU图像处理', startProgress: 58, endProgress: 74, requiresUser: true }, { step: 6, key: 'sku-names', label: 'AI SKU命名策略', startProgress: 74, endProgress: 88, requiresUser: true }, { step: 7, key: 'product-title', label: 'AI商品标题策略', startProgress: 88, endProgress: 100, requiresUser: true }, ]; const COMPANY_TASK_WORKFLOW_STEPS = [8, 3, 4, 6, 7]; // Step 5 was persisted by earlier releases. It remains a compatibility value, // but video aggregation is now owned by the automatic-listing delivery flow. function normalizeWorkflowStep(step) { const normalized = Number(step) || 0; return normalized === 5 ? 6 : normalized; } function getWorkflowStepDefinition(step) { return WORKFLOW_STEP_DEFINITIONS.find((definition) => definition.step === Number(step)); } function getWorkflowStepRank(step) { return WORKFLOW_STEP_DEFINITIONS.findIndex((definition) => definition.step === Number(step)); } function getPreviousWorkflowStep(step) { const index = WORKFLOW_STEP_DEFINITIONS.findIndex( (definition) => definition.step === Number(step) ); return index > 0 ? WORKFLOW_STEP_DEFINITIONS[index - 1].step : 0; } function getPreviousCompanyTaskWorkflowStep(step) { const index = COMPANY_TASK_WORKFLOW_STEPS.indexOf(Number(step)); return index > 0 ? COMPANY_TASK_WORKFLOW_STEPS[index - 1] : 0; } const PDD_ACTIVITY_PRICE_DEFINITIONS = [ { key: 'newCustomerDiscount', label: '新客立减' }, { key: 'productDiscount', label: '商品立减' }, { key: 'fullReduction20030', label: '满减200-30', referenceRate: 0.85 }, { key: 'fullReduction10015', label: '满减100-15', referenceRate: 0.85 }, { key: 'fullReduction101', label: '满减10-1', referenceRate: 0.9 }, { key: 'monthlyCardPrice', label: '月卡专享价' }, { key: 'majorPromotionJointCoupon', label: '大促1:1合资券' }, { key: 'majorPromotion95JointCoupon', label: '大促95折合资券', referenceRate: 0.95 }, { key: 'superEightPm', label: '超级晚八' }, { key: 'majorPromotionStorePrice99', label: '大促全店平日价降价1%', referenceRate: 0.99 }, { key: 'threeOrderChallenge', label: '三单挑战' }, { key: 'limitedTimeQuantity', label: '限时限量活动' }, { key: 'regularPromotion85', label: '普通大促0.85', referenceRate: 0.85 }, { key: 'seckill65', label: '秒杀0.65', referenceRate: 0.65 }, { key: 'billionSubsidy88', label: '百亿补贴0.88', referenceRate: 0.88 }, { key: 'billionDirectSeckill', label: '百亿直上秒杀' }, { key: 'billionSamePriceHomepage', label: '百亿同价上首页' }, { key: 'monthlyCardOfficialSubsidy95', label: '月卡会员官补0.95', referenceRate: 0.95 }, { key: 'billionConsumerCoupon90', label: '百亿消费券0.9', referenceRate: 0.9 }, { key: 'billionConsumerCouponLong95', label: '百亿消费券长期0.95', referenceRate: 0.95 }, ]; let schemaReady = false; let schemaPromise = null; let readSchemaReady = false; let readSchemaPromise = null; const activeWorkflowJobs = new Map(); const activeItemWorkflowJobs = new Map(); const pendingItemWorkflowJobs = new Map(); function createSkuImageRunSupersededError() { const error = new Error('SKU图处理方案已切换,旧任务结果已停止写入'); error.code = 'SKU_IMAGE_RUN_SUPERSEDED'; return error; } function escapeIdentifier(value) { return `\`${String(value || '').replace(/`/g, '``')}\``; } function normalizeText(value, maxLength = 1000) { return String(value ?? '').trim().slice(0, maxLength); } function normalizeNullableText(value, maxLength = 1000) { const text = normalizeText(value, maxLength); return text || null; } function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) { const numeric = Number.parseInt(value, 10); if (!Number.isFinite(numeric) || numeric <= 0) return fallback; return Math.min(numeric, max); } function toMysqlDateTime(value) { if (!value) return null; if (typeof value === 'string') { const mysqlDateTime = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/); if (mysqlDateTime && !value.includes('T')) { return `${mysqlDateTime[1]} ${mysqlDateTime[2]}`; } } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return null; return date.toISOString().slice(0, 19).replace('T', ' '); } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function readUint24LittleEndian(buffer, offset) { return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16); } function parseImageDimensions(buffer) { if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), }; } const signature = buffer.toString('ascii', 0, 6); if (signature === 'GIF87a' || signature === 'GIF89a') { return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8), }; } if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { const chunkType = buffer.toString('ascii', 12, 16); if (chunkType === 'VP8X' && buffer.length >= 30) { return { width: 1 + readUint24LittleEndian(buffer, 24), height: 1 + readUint24LittleEndian(buffer, 27), }; } if (chunkType === 'VP8L' && buffer.length >= 25 && buffer[20] === 0x2f) { return { width: 1 + ((buffer[21] | (buffer[22] << 8)) & 0x3fff), height: 1 + (((buffer[22] >> 6) | (buffer[23] << 2) | (buffer[24] << 10)) & 0x3fff), }; } if (chunkType === 'VP8 ' && buffer.length >= 30) { for (let offset = 20; offset + 10 < buffer.length; offset += 1) { if (buffer[offset] === 0x9d && buffer[offset + 1] === 0x01 && buffer[offset + 2] === 0x2a) { return { width: buffer.readUInt16LE(offset + 3) & 0x3fff, height: buffer.readUInt16LE(offset + 5) & 0x3fff, }; } } } } if (buffer[0] === 0xff && buffer[1] === 0xd8) { let offset = 2; while (offset + 9 < buffer.length) { if (buffer[offset] !== 0xff) { offset += 1; continue; } while (buffer[offset] === 0xff) offset += 1; const marker = buffer[offset]; offset += 1; if (marker === 0xd8 || marker === 0xd9) continue; if (offset + 1 >= buffer.length) break; const segmentLength = buffer.readUInt16BE(offset); if (segmentLength < 2 || offset + segmentLength > buffer.length) break; const isStartOfFrame = ( (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf) ); if (isStartOfFrame && offset + 7 < buffer.length) { return { height: buffer.readUInt16BE(offset + 3), width: buffer.readUInt16BE(offset + 5), }; } offset += segmentLength; } } return null; } function greatestCommonDivisor(left, right) { let a = Math.abs(Math.round(left)); let b = Math.abs(Math.round(right)); while (b) { const remainder = a % b; a = b; b = remainder; } return a || 1; } function formatImageAspectRatio(dimensions) { const width = Number(dimensions?.width); const height = Number(dimensions?.height); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return ''; const divisor = greatestCommonDivisor(width, height); return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; } async function getImageAspectRatio(imageUrl) { const normalizedUrl = normalizeImageUrl(imageUrl); if (!normalizedUrl) return ''; if (imageAspectRatioCache.has(normalizedUrl)) { return imageAspectRatioCache.get(normalizedUrl); } const pending = axios.get(normalizedUrl, { responseType: 'arraybuffer', timeout: 30000, maxContentLength: 12 * 1024 * 1024, headers: { 'User-Agent': 'shujuku3-selection-fission/1.0' }, }) .then((response) => formatImageAspectRatio(parseImageDimensions(Buffer.from(response.data)))) .catch(() => ''); imageAspectRatioCache.set(normalizedUrl, pending); return pending; } function hasStandardImageExtension(imageUrl) { try { return /\.(?:avif|gif|jpe?g|png|webp)$/i.test(new URL(imageUrl).pathname); } catch (_error) { return false; } } function shouldRehostImageGenerationReference(imageUrl) { try { const hostname = new URL(imageUrl).hostname.toLowerCase(); return !hasStandardImageExtension(imageUrl) || hostname === 'alicdn.com' || hostname.endsWith('.alicdn.com') || hostname === 'alicdn.net' || hostname.endsWith('.alicdn.net'); } catch (_error) { return true; } } function detectImageContent(buffer, responseContentType = '') { if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { return { mimeType: 'image/jpeg', extension: 'jpg' }; } if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { return { mimeType: 'image/png', extension: 'png' }; } if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') { return { mimeType: 'image/webp', extension: 'webp' }; } if (['GIF87a', 'GIF89a'].includes(buffer.subarray(0, 6).toString('ascii'))) { return { mimeType: 'image/gif', extension: 'gif' }; } const mimeType = String(responseContentType || '').split(';')[0].trim().toLowerCase(); const extensionByMimeType = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', }; return extensionByMimeType[mimeType] ? { mimeType, extension: extensionByMimeType[mimeType] } : null; } async function normalizeImageGenerationReferenceUrl(imageUrl, dependencies = {}) { const normalizedUrl = normalizeImageUrl(imageUrl); if (!normalizedUrl || !shouldRehostImageGenerationReference(normalizedUrl)) return normalizedUrl; const normalizeReference = async () => { const fetchImage = dependencies.fetchImage || axios.get; const uploadImage = dependencies.uploadImage || uploadBufferToDefaultS3; const isAlibabaCdn = shouldRehostImageGenerationReference(normalizedUrl) && hasStandardImageExtension(normalizedUrl); const response = await fetchImage(normalizedUrl, { responseType: 'arraybuffer', timeout: 30000, maxContentLength: 20 * 1024 * 1024, headers: isAlibabaCdn ? { Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', Referer: 'https://detail.1688.com/', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/136.0 Safari/537.36', } : { 'User-Agent': 'shujuku3-selection-fission/1.0', }, }); const buffer = Buffer.from(response.data); const imageContent = detectImageContent(buffer, response.headers?.['content-type']); if (!imageContent) { throw new Error('参考图内容不是受支持的图片格式'); } const uploaded = await uploadImage( buffer, imageContent.mimeType, 'selection-fission/reference-images', imageContent.extension ); const uploadedUrl = normalizeImageUrl(uploaded?.url); if (!uploadedUrl) throw new Error('参考图转存后未返回公开地址'); return uploadedUrl; }; if (dependencies.fetchImage || dependencies.uploadImage) return normalizeReference(); if (imageGenerationReferenceCache.has(normalizedUrl)) { return imageGenerationReferenceCache.get(normalizedUrl); } const pending = normalizeReference().catch((error) => { imageGenerationReferenceCache.delete(normalizedUrl); throw error; }); imageGenerationReferenceCache.set(normalizedUrl, pending); return pending; } function getExternalRequestErrorMessage(error, fallback) { const responseData = error?.response?.data; const responseMessage = typeof responseData === 'string' ? responseData : responseData?.message || responseData?.error?.message || responseData?.error || responseData?.detail; if (responseMessage && typeof responseMessage !== 'object') { return normalizeText(responseMessage, 1000); } return normalizeText(error?.message, 1000) || fallback; } function normalizeDecimal(value) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function normalizeNullableDecimal(value) { if (value === null || value === undefined || value === '') return null; return normalizeDecimal(value); } function parseAlibaba1688Money(value) { const text = normalizeText(value, 300).replace(/,/g, ''); if (!text) return null; const currencyMatch = text.match(/(?:¥|¥|RMB\s*)\s*(\d+(?:\.\d+)?)/i); const plainMatch = text.match(/^\s*(\d+(?:\.\d+)?)\s*$/); return normalizeNullableDecimal(currencyMatch?.[1] ?? plainMatch?.[1]); } function resolveAlibaba1688SkuPurchasePrice(sku = {}) { return parseAlibaba1688Money(sku.rowPriceText) ?? normalizeNullableDecimal(sku.price) ?? normalizeNullableDecimal(sku.purchasePrice) ?? normalizeNullableDecimal(sku.minPrice); } function advanceWorkflowProgress(currentProgress, candidateProgress) { return Math.max( Number.isFinite(Number(currentProgress)) ? Number(currentProgress) : 0, Number.isFinite(Number(candidateProgress)) ? Number(candidateProgress) : 0 ); } function safeJsonStringify(value) { try { return JSON.stringify(value ?? null); } catch (_error) { return null; } } function normalizeSqlBindValues(values = []) { return values.map((value) => ( value === undefined || (typeof value === 'number' && !Number.isFinite(value)) ? null : value )); } async function insertRowsInBatches(connection, tableName, columns, valueRows, batchSize = 25) { if (!Array.isArray(valueRows) || valueRows.length === 0) return; const normalizedBatchSize = normalizePositiveInteger(batchSize, 25, 100); const rowPlaceholder = `(${columns.map(() => '?').join(', ')}, NOW(), NOW())`; for (let index = 0; index < valueRows.length; index += normalizedBatchSize) { const batch = valueRows.slice(index, index + normalizedBatchSize); const invalidRow = batch.find((row) => !Array.isArray(row) || row.length !== columns.length); if (invalidRow) { throw new Error(`定价表写入参数数量不匹配:期望 ${columns.length} 列`); } await connection.execute( `INSERT INTO ${escapeIdentifier(tableName)} ( ${columns.map(escapeIdentifier).join(', ')}, created_at, updated_at ) VALUES ${batch.map(() => rowPlaceholder).join(', ')}`, normalizeSqlBindValues(batch.flat()) ); } } function safeJsonParse(value, fallback = null) { if (!value) return fallback; if (typeof value === 'object') return value; try { return JSON.parse(value); } catch (_error) { return fallback; } } function normalizePriceBandList(...values) { const result = []; const append = (value) => { if (Array.isArray(value)) { value.forEach(append); return; } if (value === null || value === undefined || value === '') return; if (typeof value === 'string' && value.trim().startsWith('[')) { const parsed = safeJsonParse(value, null); if (Array.isArray(parsed)) { parsed.forEach(append); return; } } const normalized = normalizeText(value, 120); if (normalized && !result.includes(normalized)) result.push(normalized); }; values.forEach(append); return result; } function sortPriceBandValues(values = []) { return normalizePriceBandList(values).sort((left, right) => { if (left === '不限') return -1; if (right === '不限') return 1; const leftBase = Number.parseFloat(left.match(/-?\d+(?:\.\d+)?/)?.[0] || ''); const rightBase = Number.parseFloat(right.match(/-?\d+(?:\.\d+)?/)?.[0] || ''); const normalizedLeft = Number.isFinite(leftBase) ? leftBase : Number.MAX_SAFE_INTEGER; const normalizedRight = Number.isFinite(rightBase) ? rightBase : Number.MAX_SAFE_INTEGER; if (normalizedLeft !== normalizedRight) return normalizedLeft - normalizedRight; return left.localeCompare(right, 'zh-CN'); }); } function getFirstEnvValue(names, fallback = '') { for (const name of names) { const value = process.env[name]; if (value !== undefined && value !== null && String(value).trim() !== '') { return String(value).trim(); } } return fallback; } function stripTrailingSlash(value) { return normalizeText(value, 500).replace(/\/+$/, ''); } function normalizeImageUrl(value) { const raw = normalizeText(value, 2000); if (!raw) return ''; return raw.startsWith('//') ? `https:${raw}` : raw; } function createExternalServiceError(message, status = 503, code = 'EXTERNAL_SERVICE_NOT_CONFIGURED') { const error = new Error(message); error.status = status; error.statusCode = status; error.code = code; return error; } function getActorId(user = {}) { return user.id || user.sub || user.userId || null; } function getDingtalkUserName(user = {}) { return normalizeText(user.name || user.userName || user.username || user.email || '未识别用户', 100); } function getDepartmentName(user = {}) { return normalizeText(user.departmentName || user.department_name || user.department || '', 100); } function hashClaimToken(value) { return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex'); } function normalizeListingShop(raw = {}, index = 0) { const shopId = normalizeText( raw.shopId || raw.shop_id || raw.shopCode || raw.shop_code || raw.externalShopId || raw.external_shop_id || raw.id, 160 ); const shopName = normalizeText(raw.shopName || raw.shop_name || raw.name, 255); if (!shopId) { const error = new Error(`第 ${index + 1} 个店铺缺少 shopId 或 shopCode`); error.status = 400; throw error; } if (!shopName) { const error = new Error(`第 ${index + 1} 个店铺缺少 shopName`); error.status = 400; throw error; } return { shopId, shopCode: normalizeText(raw.shopCode || raw.shop_code, 160), shopName, platform: normalizeText(raw.platform || raw.channelName || raw.channel_name, 80), accountName: normalizeText(raw.accountName || raw.account_name, 160), status: raw.status === 'inactive' || raw.enabled === false ? 'inactive' : 'active', clientId: normalizeText(raw.clientId || raw.client_id, 64).toLowerCase(), loginStatus: normalizeListingShopLoginStatus( raw.loginStatus || raw.login_status || raw.authStatus || raw.auth_status ), loginMessage: normalizeText(raw.loginMessage || raw.login_message, 500), lastLoginCheckedAt: toMysqlDateTime( raw.lastLoginCheckedAt || raw.last_login_checked_at || raw.loginCheckedAt ), }; } function normalizeListingShopLoginStatus(value) { const normalized = normalizeText(value, 32).toLowerCase(); return [ 'authenticated', 'login_required', 'expired', 'failed', 'unknown', ].includes(normalized) ? normalized : 'unknown'; } function mapListingShop(row = {}) { return { id: Number(row.id || 0), dingtalkUserName: normalizeText(row.dingtalk_user_name || row.dingtalkUserName, 120), shopId: normalizeText(row.shop_id || row.shopId, 160), shopCode: normalizeText(row.shop_code || row.shopCode, 160), shopName: normalizeText(row.shop_name || row.shopName, 255), platform: normalizeText(row.platform, 80), accountName: normalizeText(row.account_name || row.accountName, 160), status: normalizeText(row.status || 'inactive', 20), enabled: (row.status || 'inactive') === 'active', clientId: normalizeText(row.client_id || row.clientId, 64).toLowerCase(), loginStatus: normalizeListingShopLoginStatus(row.login_status || row.loginStatus), loginMessage: normalizeText(row.login_message || row.loginMessage, 500), lastLoginCheckedAt: row.last_login_checked_at || row.lastLoginCheckedAt || null, lastSeenAt: row.last_seen_at || row.lastSeenAt || null, apiKeyId: normalizeText(row.api_key_id || row.apiKeyId, 120), apiKeyName: normalizeText(row.api_key_name || row.apiKeyName, 120), lastSyncedAt: row.last_synced_at || row.lastSyncedAt || null, createdAt: row.created_at || row.createdAt || null, updatedAt: row.updated_at || row.updatedAt || null, }; } function findDesktopPresenceUser(presence = {}, dingtalkUserName = '') { const target = normalizeText(dingtalkUserName, 120).toLowerCase(); if (!target) return null; return (Array.isArray(presence.users) ? presence.users : []).find((user) => ( [user.name, user.username] .map((value) => normalizeText(value, 120).toLowerCase()) .includes(target) )) || null; } function decorateListingShopsWithPresence(shops = [], presence = {}, dingtalkUserName = '') { const presenceUser = findDesktopPresenceUser(presence, dingtalkUserName); const onlineDevices = (Array.isArray(presenceUser?.devices) ? presenceUser.devices : []) .filter((device) => device.isOnline); const onlineDeviceIds = new Set( onlineDevices.map((device) => normalizeText(device.clientId, 64).toLowerCase()).filter(Boolean) ); const rows = shops.map((shop) => { const clientOnline = shop.clientId ? onlineDeviceIds.has(shop.clientId) : onlineDevices.length > 0; const loginValid = shop.loginStatus === 'authenticated'; return { ...shop, clientOnline, loginValid, selectable: Boolean(shop.enabled && clientOnline && loginValid), }; }); return { rows, desktop: { isOnline: onlineDevices.length > 0, activeDeviceCount: onlineDevices.length, lastSeenAt: presenceUser?.lastSeenAt || null, offlineAfterSeconds: Number(presence.offlineAfterSeconds || 120), }, }; } function getPresenceUserIdentityValues(user = {}) { return [user.name, user.username] .map((value) => normalizeText(value, 120).toLowerCase()) .filter(Boolean); } function isSameListingUser(left = {}, right = {}) { const leftValues = new Set(getPresenceUserIdentityValues(left)); return getPresenceUserIdentityValues(right).some((value) => leftValues.has(value)); } function canDelegateListingExecution(requester = {}, executor = {}) { if (isSameListingUser(requester, executor)) return true; if (normalizeText(requester.role, 20).toLowerCase() === 'admin') return true; const requesterDepartment = normalizeText( requester.departmentName || requester.department_name || requester.departmentNames, 255 ).toLowerCase(); const executorDepartment = normalizeText( executor.departmentName || executor.department_name || executor.departmentNames, 255 ).toLowerCase(); return Boolean(requesterDepartment && executorDepartment && requesterDepartment === executorDepartment); } function buildSelectionFissionExecutionOptions(shopRows = [], presence = {}, requester = {}) { const requesterIdentity = { ...requester, name: getDingtalkUserName(requester), username: normalizeText(requester.username || requester.userName || requester.email, 120), }; const presenceUsers = Array.isArray(presence.users) ? presence.users : []; const ownShopIds = new Set(shopRows .map(mapListingShop) .filter((shop) => isSameListingUser(requesterIdentity, { name: shop.dingtalkUserName, username: shop.dingtalkUserName, })) .map((shop) => shop.shopId)); const sessions = []; for (const rawShop of shopRows) { const shop = mapListingShop(rawShop); const presenceUser = findDesktopPresenceUser(presence, shop.dingtalkUserName); const isSelf = isSameListingUser(requesterIdentity, { name: shop.dingtalkUserName, username: shop.dingtalkUserName, }); const executorIdentity = presenceUser || { name: shop.dingtalkUserName, username: shop.dingtalkUserName, departmentName: '', devices: [], }; if (!isSelf && !canDelegateListingExecution(requesterIdentity, executorIdentity)) continue; if ( !isSelf && normalizeText(requesterIdentity.role, 20).toLowerCase() !== 'admin' && !ownShopIds.has(shop.shopId) ) continue; const onlineDevices = (Array.isArray(presenceUser?.devices) ? presenceUser.devices : []) .filter((device) => device.isOnline); const selectedDevice = shop.clientId ? onlineDevices.find((device) => ( normalizeText(device.clientId, 64).toLowerCase() === shop.clientId )) : onlineDevices[0]; const loginValid = shop.loginStatus === 'authenticated'; const clientOnline = Boolean(selectedDevice); const selectable = Boolean(shop.enabled && loginValid && clientOnline); // Team sessions are only disclosed when they are currently usable. The // requester can still see their own unavailable shops for recovery hints. if (!isSelf && !selectable) continue; sessions.push({ ...shop, sessionId: String(shop.id), isSelf, delegated: !isSelf, routingMode: isSelf ? 'self' : 'delegated', executorUserId: Number(presenceUser?.userId || (isSelf ? getActorId(requester) : 0)) || null, executorUserName: normalizeText(presenceUser?.name || shop.dingtalkUserName, 120), executorUsername: normalizeText(presenceUser?.username, 120), executorDepartmentName: normalizeText(presenceUser?.departmentName, 255), executorClientId: normalizeText(selectedDevice?.clientId || shop.clientId, 64).toLowerCase(), executorDeviceName: normalizeText( selectedDevice?.deviceName || selectedDevice?.clientName, 255 ), executorPlatform: normalizeText(selectedDevice?.platform, 32), clientOnline, loginValid, selectable, lastSeenAt: selectedDevice?.lastSeenAt || shop.lastSeenAt || null, }); } sessions.sort((left, right) => { if (left.selectable !== right.selectable) return left.selectable ? -1 : 1; if (left.isSelf !== right.isSelf) return left.isSelf ? -1 : 1; return String(right.lastSeenAt || '').localeCompare(String(left.lastSeenAt || '')); }); const shopsById = new Map(); for (const session of sessions) { const key = session.shopId; const existing = shopsById.get(key) || { shopId: session.shopId, shopCode: session.shopCode, shopName: session.shopName, platform: session.platform, accountName: session.accountName, sessions: [], }; existing.sessions.push(session); shopsById.set(key, existing); } const shops = Array.from(shopsById.values()).map((shop) => { const recommended = shop.sessions.find((session) => session.selectable) || shop.sessions[0]; return { ...shop, selectable: shop.sessions.some((session) => session.selectable), availableSessionCount: shop.sessions.filter((session) => session.selectable).length, recommendedSessionId: recommended?.selectable ? recommended.sessionId : null, sessions: shop.sessions, }; }); shops.sort((left, right) => { const leftSession = left.sessions.find((session) => session.sessionId === left.recommendedSessionId); const rightSession = right.sessions.find((session) => session.sessionId === right.recommendedSessionId); if (Boolean(leftSession?.isSelf) !== Boolean(rightSession?.isSelf)) return leftSession?.isSelf ? -1 : 1; if (left.selectable !== right.selectable) return left.selectable ? -1 : 1; return left.shopName.localeCompare(right.shopName, 'zh-CN'); }); const recommendedShop = shops.find((shop) => ( shop.sessions.some((session) => session.selectable && session.isSelf) )) || null; return { shops, sessions, recommended: recommendedShop ? { listingShopId: recommendedShop.shopId, executorSessionId: recommendedShop.recommendedSessionId, } : null, availableSessionCount: sessions.filter((session) => session.selectable).length, onlineUserCount: presenceUsers.filter((user) => user.isOnline).length, }; } function buildSchemeCode(now = new Date()) { const pad = (value) => String(value).padStart(2, '0'); const date = [ now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate()), pad(now.getHours()), pad(now.getMinutes()), ].join(''); const suffix = crypto.randomBytes(3).toString('hex').toUpperCase(); return `FB${date}${suffix}`; } async function ensureSelectionFissionPlanColumn(columnName, definition) { return ensureSelectionFissionTableColumn(PLAN_TABLE, columnName, definition); } async function ensureSelectionFissionTableColumn(tableName, columnName, definition) { const rows = await planningDatabase.query( `SHOW COLUMNS FROM ${escapeIdentifier(tableName)} LIKE ?`, [columnName] ); if (rows[0]) return false; await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(tableName)} ADD COLUMN ${escapeIdentifier(columnName)} ${definition}` ); return true; } async function ensureSelectionFissionTableIndex(tableName, indexName, definition) { const rows = await planningDatabase.query( `SHOW INDEX FROM ${escapeIdentifier(tableName)} WHERE Key_name = ?`, [indexName] ); if (rows[0]) return false; await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(tableName)} ADD ${definition}` ); return true; } async function ensureSelectionFissionSchema() { if (schemaReady) return; if (schemaPromise) { await schemaPromise; return; } schemaPromise = (async () => { if (await isSelectionFissionSchemaCurrent()) { schemaReady = true; readSchemaReady = true; return; } await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(PLAN_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', scheme_code VARCHAR(64) NOT NULL COMMENT '裂变方案编码', dingtalk_user_id VARCHAR(120) NULL COMMENT '钉钉/系统用户ID', dingtalk_username VARCHAR(120) NULL COMMENT '钉钉/系统账号', dingtalk_user_name VARCHAR(120) NULL COMMENT '钉钉用户显示名', department_name VARCHAR(120) NULL COMMENT '部门名称', workflow_status ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT '裂变工作流状态', workflow_step INT NOT NULL DEFAULT 0 COMMENT '当前裂变节点', workflow_progress DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '裂变进度百分比', workflow_error_message VARCHAR(1000) NULL COMMENT '裂变失败原因', workflow_started_at DATETIME NULL COMMENT '裂变开始时间', workflow_completed_at DATETIME NULL COMMENT '裂变完成时间', status ENUM('pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'pending' COMMENT '自动上架任务状态', item_count INT NOT NULL DEFAULT 5 COMMENT '市场ID数量', listed_item_count INT NOT NULL DEFAULT 0 COMMENT '已绑定上架ID数量', listing_shop_id VARCHAR(160) NULL COMMENT '上架店铺ID快照', listing_shop_code VARCHAR(160) NULL COMMENT '上架店铺编码快照', listing_shop_name VARCHAR(255) NULL COMMENT '上架店铺名称快照', listing_shop_platform VARCHAR(80) NULL COMMENT '上架店铺平台快照', listing_shop_snapshot_json LONGTEXT NULL COMMENT '上架店铺完整快照', claim_token_hash CHAR(64) NULL COMMENT '任务领取令牌摘要', claimed_by VARCHAR(160) NULL COMMENT '领取任务的自动化进程', claimed_at DATETIME NULL COMMENT '最近领取时间', claim_expires_at DATETIME NULL COMMENT '领取锁失效时间', claim_count INT NOT NULL DEFAULT 0 COMMENT '累计领取次数', auto_listed_product_id VARCHAR(160) NULL COMMENT '第三方自动上架后的商品ID', auto_listing_completed_at DATETIME NULL COMMENT '自动上架结果回传时间', auto_listing_failed_at DATETIME NULL COMMENT '最近上架失败回传时间', auto_listing_error_code VARCHAR(120) NULL COMMENT '最近上架失败错误码', auto_listing_error_message VARCHAR(1000) NULL COMMENT '最近上架失败原因', auto_listing_callback_json LONGTEXT NULL COMMENT '自动上架回调记录', created_by_user_id VARCHAR(120) NULL COMMENT '创建用户ID', created_by_username VARCHAR(120) NULL COMMENT '创建用户名', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_fission_scheme (scheme_code), KEY idx_planning_selection_fission_user (dingtalk_user_id, updated_at), KEY idx_planning_selection_fission_status (status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案'` ); const statusColumns = await planningDatabase.query( `SHOW COLUMNS FROM ${escapeIdentifier(PLAN_TABLE)} LIKE 'status'` ); if (!String(statusColumns[0]?.Type || '').includes("'claimed'")) { await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(PLAN_TABLE)} MODIFY COLUMN status ENUM('pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'pending' COMMENT '自动上架任务状态'` ); } await planningDatabase.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET status = 'pending' WHERE status = 'listed' AND NULLIF(TRIM(auto_listed_product_id), '') IS NULL` ); await ensureSelectionFissionPlanColumn( 'workflow_status', "ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT '裂变工作流状态' AFTER department_name" ); await ensureSelectionFissionPlanColumn( 'workflow_step', "INT NOT NULL DEFAULT 0 COMMENT '当前裂变节点' AFTER workflow_status" ); await ensureSelectionFissionPlanColumn( 'workflow_progress', "DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '裂变进度百分比' AFTER workflow_step" ); await ensureSelectionFissionPlanColumn( 'workflow_error_message', "VARCHAR(1000) NULL COMMENT '裂变失败原因' AFTER workflow_progress" ); await ensureSelectionFissionPlanColumn( 'workflow_started_at', "DATETIME NULL COMMENT '裂变开始时间' AFTER workflow_error_message" ); await ensureSelectionFissionPlanColumn( 'workflow_completed_at', "DATETIME NULL COMMENT '裂变完成时间' AFTER workflow_started_at" ); await ensureSelectionFissionPlanColumn( 'listing_shop_id', "VARCHAR(160) NULL COMMENT '上架店铺ID快照' AFTER listed_item_count" ); await ensureSelectionFissionPlanColumn( 'listing_shop_code', "VARCHAR(160) NULL COMMENT '上架店铺编码快照' AFTER listing_shop_id" ); await ensureSelectionFissionPlanColumn( 'listing_shop_name', "VARCHAR(255) NULL COMMENT '上架店铺名称快照' AFTER listing_shop_code" ); await ensureSelectionFissionPlanColumn( 'listing_shop_platform', "VARCHAR(80) NULL COMMENT '上架店铺平台快照' AFTER listing_shop_name" ); await ensureSelectionFissionPlanColumn( 'listing_shop_snapshot_json', "LONGTEXT NULL COMMENT '上架店铺完整快照' AFTER listing_shop_platform" ); await ensureSelectionFissionPlanColumn( 'claim_token_hash', "CHAR(64) NULL COMMENT '任务领取令牌摘要' AFTER listing_shop_snapshot_json" ); await ensureSelectionFissionPlanColumn( 'claimed_by', "VARCHAR(160) NULL COMMENT '领取任务的自动化进程' AFTER claim_token_hash" ); await ensureSelectionFissionPlanColumn( 'claimed_at', "DATETIME NULL COMMENT '最近领取时间' AFTER claimed_by" ); await ensureSelectionFissionPlanColumn( 'claim_expires_at', "DATETIME NULL COMMENT '领取锁失效时间' AFTER claimed_at" ); await ensureSelectionFissionPlanColumn( 'claim_count', "INT NOT NULL DEFAULT 0 COMMENT '累计领取次数' AFTER claim_expires_at" ); await ensureSelectionFissionPlanColumn( 'auto_listed_product_id', "VARCHAR(160) NULL COMMENT '第三方自动上架后的商品ID' AFTER claim_count" ); await ensureSelectionFissionPlanColumn( 'auto_listing_completed_at', "DATETIME NULL COMMENT '自动上架结果回传时间' AFTER auto_listed_product_id" ); await ensureSelectionFissionPlanColumn( 'auto_listing_failed_at', "DATETIME NULL COMMENT '最近上架失败回传时间' AFTER auto_listing_completed_at" ); await ensureSelectionFissionPlanColumn( 'auto_listing_error_code', "VARCHAR(120) NULL COMMENT '最近上架失败错误码' AFTER auto_listing_failed_at" ); await ensureSelectionFissionPlanColumn( 'auto_listing_error_message', "VARCHAR(1000) NULL COMMENT '最近上架失败原因' AFTER auto_listing_error_code" ); await ensureSelectionFissionPlanColumn( 'auto_listing_callback_json', "LONGTEXT NULL COMMENT '自动上架回调记录' AFTER auto_listing_error_message" ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(USER_SHOP_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '钉钉用户名', shop_id VARCHAR(160) NOT NULL COMMENT '第三方店铺ID', shop_code VARCHAR(160) NULL COMMENT '第三方店铺编码', shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称', platform VARCHAR(80) NULL COMMENT '平台', account_name VARCHAR(160) NULL COMMENT '第三方店铺账号显示名', status ENUM('active', 'inactive') NOT NULL DEFAULT 'active' COMMENT '可选状态', client_id VARCHAR(64) NULL COMMENT '最近同步店铺状态的桌面设备ID', login_status VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '桌面端店铺登录状态', login_message VARCHAR(500) NULL COMMENT '桌面端店铺登录状态说明', last_login_checked_at DATETIME NULL COMMENT '桌面端最近检测店铺登录状态时间', last_seen_at DATETIME NULL COMMENT '桌面端最近同步店铺状态时间', api_key_id VARCHAR(120) NULL COMMENT '最近同步API Key记录ID', api_key_name VARCHAR(120) NULL COMMENT '最近同步API Key名称', last_synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近同步时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_user_shop (dingtalk_user_name, shop_id), KEY idx_planning_selection_user_shop_status (dingtalk_user_name, status, shop_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钉钉用户自动上架店铺'` ); await ensureSelectionFissionTableColumn( USER_SHOP_TABLE, 'client_id', "VARCHAR(64) NULL COMMENT '最近同步店铺状态的桌面设备ID' AFTER status" ); await ensureSelectionFissionTableColumn( USER_SHOP_TABLE, 'login_status', "VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '桌面端店铺登录状态' AFTER client_id" ); await ensureSelectionFissionTableColumn( USER_SHOP_TABLE, 'login_message', "VARCHAR(500) NULL COMMENT '桌面端店铺登录状态说明' AFTER login_status" ); await ensureSelectionFissionTableColumn( USER_SHOP_TABLE, 'last_login_checked_at', "DATETIME NULL COMMENT '桌面端最近检测店铺登录状态时间' AFTER login_message" ); await ensureSelectionFissionTableColumn( USER_SHOP_TABLE, 'last_seen_at', "DATETIME NULL COMMENT '桌面端最近同步店铺状态时间' AFTER last_login_checked_at" ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(LISTING_RESULT_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', plan_item_id BIGINT NULL COMMENT 'SPU自动上架子任务ID', task_code VARCHAR(64) NOT NULL COMMENT '选品任务编码', product_id VARCHAR(160) NOT NULL COMMENT '第三方自动上架后的商品ID', dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '任务所属钉钉用户名', api_key_id VARCHAR(120) NULL COMMENT '调用方API Key记录ID', api_key_name VARCHAR(120) NULL COMMENT '调用方API Key名称', callback_payload_json LONGTEXT NULL COMMENT '脱敏后的回调记录', callback_count INT NOT NULL DEFAULT 1 COMMENT '相同结果回传次数', is_current TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否为任务当前商品ID', first_received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次回传时间', last_received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近回传时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_listing_result (plan_id, product_id), KEY idx_planning_selection_listing_task (plan_id, is_current, last_received_at), KEY idx_planning_selection_listing_product (product_id), CONSTRAINT fk_planning_selection_listing_result_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品任务自动上架商品ID回传记录'` ); await ensureSelectionFissionTableColumn( LISTING_RESULT_TABLE, 'plan_item_id', "BIGINT NULL COMMENT 'SPU自动上架子任务ID' AFTER plan_id" ); await ensureSelectionFissionTableIndex( LISTING_RESULT_TABLE, 'idx_planning_selection_listing_item', 'KEY idx_planning_selection_listing_item (plan_item_id, is_current, last_received_at)' ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', plan_item_id BIGINT NULL COMMENT 'SPU自动上架子任务ID', task_code VARCHAR(64) NOT NULL COMMENT '选品任务编码', callback_type ENUM('success', 'failure') NOT NULL COMMENT '回传类型', product_id VARCHAR(160) NULL COMMENT '自动上架商品ID', error_code VARCHAR(120) NULL COMMENT '失败错误码', error_message VARCHAR(1000) NULL COMMENT '失败原因', dingtalk_user_name VARCHAR(120) NOT NULL COMMENT '任务所属钉钉用户名', worker_id VARCHAR(160) NULL COMMENT '自动化进程标识', api_key_id VARCHAR(120) NULL COMMENT '调用方API Key记录ID', api_key_name VARCHAR(120) NULL COMMENT '调用方API Key名称', remote_ip VARCHAR(80) NULL COMMENT '回传来源IP', callback_payload_json LONGTEXT NULL COMMENT '脱敏后的回传记录', received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '回传时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_planning_selection_callback_plan (plan_id, received_at, id), KEY idx_planning_selection_callback_type (callback_type, received_at), CONSTRAINT fk_planning_selection_callback_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品任务自动上架回传历史'` ); await ensureSelectionFissionTableColumn( CALLBACK_HISTORY_TABLE, 'plan_item_id', "BIGINT NULL COMMENT 'SPU自动上架子任务ID' AFTER plan_id" ); await ensureSelectionFissionTableIndex( CALLBACK_HISTORY_TABLE, 'idx_planning_selection_callback_item', 'KEY idx_planning_selection_callback_item (plan_item_id, received_at, id)' ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(REVISION_HISTORY_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '选品任务数字ID', plan_item_id BIGINT NULL COMMENT '商品子任务ID', revision_type VARCHAR(64) NOT NULL COMMENT '版本类型', revision_no INT NOT NULL DEFAULT 1 COMMENT '同类型版本号', summary VARCHAR(500) NULL COMMENT '版本摘要', snapshot_json LONGTEXT NULL COMMENT '当时的源证据和用户确认结果', created_by VARCHAR(120) NULL COMMENT '操作人', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_planning_selection_revision_plan (plan_id, created_at, id), KEY idx_planning_selection_revision_item (plan_item_id, revision_type, revision_no), CONSTRAINT fk_planning_selection_revision_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688待上架运营操作版本记录'` ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(ITEM_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '方案ID', source_item_id BIGINT NULL COMMENT '所属市场ID源明细', slot_no INT NOT NULL COMMENT '方案内序号', variant_no INT NOT NULL DEFAULT 1 COMMENT '同市场ID下SPU任务序号', task_code VARCHAR(96) NULL COMMENT '独立自动上架任务编码', market_item_id VARCHAR(160) NOT NULL COMMENT '市场商品ID', channel_name VARCHAR(80) NULL COMMENT '平台', title VARCHAR(500) NULL COMMENT '市场商品标题', shop_name VARCHAR(255) NULL COMMENT '市场店铺', category_level1 VARCHAR(255) NULL COMMENT '一级品类', category_level2 VARCHAR(255) NULL COMMENT '二级品类', category_level3 VARCHAR(255) NULL COMMENT '三级品类', main_image_url VARCHAR(2000) NULL COMMENT '市场主图第一张', market_detail_url VARCHAR(2000) NULL COMMENT '市场详情链接', price_band VARCHAR(120) NULL COMMENT '榜单价格带主值', price_band_list_json LONGTEXT NULL COMMENT '榜单价格带完整集合', replaced_image_url VARCHAR(2000) NULL COMMENT '去水印/替换产品后的市场图', selected_spu_code VARCHAR(160) NULL COMMENT '用户选择的公司SPU', selected_spu_name VARCHAR(255) NULL COMMENT '用户选择的公司SPU名称', selected_spu_payload_json LONGTEXT NULL COMMENT '选择SPU时的候选数据', white_background_assets_json LONGTEXT NULL COMMENT 'SPU白底图候选', selected_main_sku_code VARCHAR(160) NULL COMMENT '用户选择的主图展示SKU', selected_main_sku_name VARCHAR(255) NULL COMMENT '用户选择的主图展示SKU名称', selected_main_sku_image_url VARCHAR(2000) NULL COMMENT '主图展示SKU白底图', main_image_generation_json LONGTEXT NULL COMMENT '主图替换生图响应', sku_scene_images_json LONGTEXT NULL COMMENT 'SKU场景图生成结果', sku_name_candidates_json LONGTEXT NULL COMMENT 'SKU AI名称候选', title_keyword_strategy_json LONGTEXT NULL COMMENT '商品标题关键词策略', optimized_title_candidates_json LONGTEXT NULL COMMENT 'AI商品标题候选', selected_product_title VARCHAR(500) NULL COMMENT '用户最终选择的商品标题', title_ai_model VARCHAR(160) NULL COMMENT '标题生成模型', title_ai_generated_at DATETIME NULL COMMENT '标题生成时间', workflow_step INT NOT NULL DEFAULT 0 COMMENT 'SPU任务当前裂变节点', workflow_status ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT 'SPU任务工作流状态', workflow_error_message VARCHAR(1000) NULL COMMENT 'SPU任务失败原因', listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', listing_status ENUM('draft', 'pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'draft' COMMENT '上架状态', claim_token_hash CHAR(64) NULL COMMENT 'SPU任务领取令牌摘要', claimed_by VARCHAR(160) NULL COMMENT '领取任务的自动化进程', claimed_at DATETIME NULL COMMENT '领取时间', claim_expires_at DATETIME NULL COMMENT '领取锁失效时间', claim_count INT NOT NULL DEFAULT 0 COMMENT '累计领取次数', listing_error_code VARCHAR(120) NULL COMMENT '上架失败错误码', listing_error_message VARCHAR(1000) NULL COMMENT '上架失败原因', listing_completed_at DATETIME NULL COMMENT '上架完成时间', pdd_draft_commit_id VARCHAR(160) NULL COMMENT '拼多多草稿提交ID', pdd_draft_config_json LONGTEXT NULL COMMENT '拼多多草稿发布配置快照', pdd_draft_request_hash CHAR(64) NULL COMMENT '拼多多草稿请求指纹', pdd_draft_request_json LONGTEXT NULL COMMENT '拼多多草稿请求快照', pdd_draft_response_json LONGTEXT NULL COMMENT '拼多多草稿响应摘要', pdd_attribute_ai_progress_json LONGTEXT NULL COMMENT '拼多多属性OCR与AI任务进度', pdd_attribute_ai_result_json LONGTEXT NULL COMMENT '拼多多属性智能推荐结果', similar_response_json LONGTEXT NULL COMMENT '以图搜图原始响应', market_reference_batch_json LONGTEXT NULL COMMENT '市场相似款当前推荐批次', selected_market_references_json LONGTEXT NULL COMMENT '用户选择的五个市场生图参考款', sku_snapshot_json LONGTEXT NULL COMMENT 'SPU下SKU明细快照', video_assets_json LONGTEXT NULL COMMENT '视频素材响应', freight_profile_json LONGTEXT NULL COMMENT '运费/售后补充信息', market_profile_json LONGTEXT NULL COMMENT '市场ID详情补充信息', metadata_json LONGTEXT NULL COMMENT '原始市场行数据', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_fission_item_variant (plan_id, market_item_id, variant_no), UNIQUE KEY uk_planning_selection_fission_task_code (task_code), KEY idx_planning_selection_fission_item_plan (plan_id, slot_no), KEY idx_planning_selection_fission_item_market (market_item_id), KEY idx_planning_selection_fission_item_spu (selected_spu_code), CONSTRAINT fk_planning_selection_fission_item_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案市场ID明细'` ); const itemColumnDefinitions = [ ['source_item_id', "BIGINT NULL COMMENT '所属市场ID源明细' AFTER plan_id"], ['variant_no', "INT NOT NULL DEFAULT 1 COMMENT '同市场ID下SPU任务序号' AFTER slot_no"], ['task_code', "VARCHAR(96) NULL COMMENT '独立自动上架任务编码' AFTER variant_no"], ['price_band', "VARCHAR(120) NULL COMMENT '榜单价格带主值' AFTER market_detail_url"], ['price_band_list_json', "LONGTEXT NULL COMMENT '榜单价格带完整集合' AFTER price_band"], ['white_background_assets_json', "LONGTEXT NULL COMMENT 'SPU白底图候选' AFTER selected_spu_payload_json"], ['selected_main_sku_code', "VARCHAR(160) NULL COMMENT '用户选择的主图展示SKU' AFTER white_background_assets_json"], ['selected_main_sku_name', "VARCHAR(255) NULL COMMENT '用户选择的主图展示SKU名称' AFTER selected_main_sku_code"], ['selected_main_sku_image_url', "VARCHAR(2000) NULL COMMENT '主图展示SKU白底图' AFTER selected_main_sku_name"], ['main_image_generation_json', "LONGTEXT NULL COMMENT '主图替换生图响应' AFTER selected_main_sku_image_url"], ['sku_scene_images_json', "LONGTEXT NULL COMMENT 'SKU场景图生成结果' AFTER main_image_generation_json"], ['sku_name_candidates_json', "LONGTEXT NULL COMMENT 'SKU AI名称候选' AFTER sku_scene_images_json"], ['title_keyword_strategy_json', "LONGTEXT NULL COMMENT '商品标题关键词策略' AFTER sku_name_candidates_json"], ['optimized_title_candidates_json', "LONGTEXT NULL COMMENT 'AI商品标题候选' AFTER sku_name_candidates_json"], ['selected_product_title', "VARCHAR(500) NULL COMMENT '用户最终选择的商品标题' AFTER optimized_title_candidates_json"], ['title_ai_model', "VARCHAR(160) NULL COMMENT '标题生成模型' AFTER selected_product_title"], ['title_ai_generated_at', "DATETIME NULL COMMENT '标题生成时间' AFTER title_ai_model"], ['workflow_step', "INT NOT NULL DEFAULT 0 COMMENT 'SPU任务当前裂变节点' AFTER title_ai_generated_at"], ['workflow_status', "ENUM('draft', 'running', 'waiting', 'completed', 'failed') NOT NULL DEFAULT 'draft' COMMENT 'SPU任务工作流状态' AFTER workflow_step"], ['workflow_error_message', "VARCHAR(1000) NULL COMMENT 'SPU任务失败原因' AFTER workflow_status"], ['claim_token_hash', "CHAR(64) NULL COMMENT 'SPU任务领取令牌摘要' AFTER listing_status"], ['claimed_by', "VARCHAR(160) NULL COMMENT '领取任务的自动化进程' AFTER claim_token_hash"], ['claimed_at', "DATETIME NULL COMMENT '领取时间' AFTER claimed_by"], ['claim_expires_at', "DATETIME NULL COMMENT '领取锁失效时间' AFTER claimed_at"], ['claim_count', "INT NOT NULL DEFAULT 0 COMMENT '累计领取次数' AFTER claim_expires_at"], ['listing_error_code', "VARCHAR(120) NULL COMMENT '上架失败错误码' AFTER claim_count"], ['listing_error_message', "VARCHAR(1000) NULL COMMENT '上架失败原因' AFTER listing_error_code"], ['listing_completed_at', "DATETIME NULL COMMENT '上架完成时间' AFTER listing_error_message"], ['pdd_draft_commit_id', "VARCHAR(160) NULL COMMENT '拼多多草稿提交ID' AFTER listing_completed_at"], ['pdd_draft_config_json', "LONGTEXT NULL COMMENT '拼多多草稿发布配置快照' AFTER pdd_draft_commit_id"], ['pdd_draft_request_hash', "CHAR(64) NULL COMMENT '拼多多草稿请求指纹' AFTER pdd_draft_config_json"], ['pdd_draft_request_json', "LONGTEXT NULL COMMENT '拼多多草稿请求快照' AFTER pdd_draft_request_hash"], ['pdd_draft_response_json', "LONGTEXT NULL COMMENT '拼多多草稿响应摘要' AFTER pdd_draft_request_json"], ['pdd_attribute_ai_progress_json', "LONGTEXT NULL COMMENT '拼多多属性OCR与AI任务进度' AFTER pdd_draft_response_json"], ['pdd_attribute_ai_result_json', "LONGTEXT NULL COMMENT '拼多多属性智能推荐结果' AFTER pdd_attribute_ai_progress_json"], ['pricing_table_saved_at', "DATETIME NULL COMMENT '利润测算定价表保存时间' AFTER listing_completed_at"], ['pricing_table_saved_by', "VARCHAR(120) NULL COMMENT '利润测算定价表保存人' AFTER pricing_table_saved_at"], ['pricing_table_version', "INT NOT NULL DEFAULT 0 COMMENT '利润测算定价表版本' AFTER pricing_table_saved_by"], ['product_code_status', "VARCHAR(32) NOT NULL DEFAULT 'not_requested' COMMENT '公司SKU编码申请状态' AFTER pricing_table_version"], ['product_code_style_code', "VARCHAR(160) NULL COMMENT '公司款式编码' AFTER product_code_status"], ['product_code_request_hash', "CHAR(64) NULL COMMENT '公司编码请求指纹' AFTER product_code_style_code"], ['product_code_config_json', "LONGTEXT NULL COMMENT '公司编码枚举配置' AFTER product_code_request_hash"], ['product_code_request_json', "LONGTEXT NULL COMMENT '公司编码请求快照' AFTER product_code_config_json"], ['product_code_response_json', "LONGTEXT NULL COMMENT '公司编码响应快照' AFTER product_code_request_json"], ['product_code_error_message', "VARCHAR(1000) NULL COMMENT '公司编码申请错误' AFTER product_code_response_json"], ['product_code_requested_at', "DATETIME NULL COMMENT '公司编码申请时间' AFTER product_code_error_message"], ['product_code_completed_at', "DATETIME NULL COMMENT '公司编码完成时间' AFTER product_code_requested_at"], ['market_reference_batch_json', "LONGTEXT NULL COMMENT '市场相似款当前推荐批次' AFTER similar_response_json"], ['selected_market_references_json', "LONGTEXT NULL COMMENT '用户选择的五个市场生图参考款' AFTER market_reference_batch_json"], ]; for (const [columnName, definition] of itemColumnDefinitions) { await ensureSelectionFissionTableColumn(ITEM_TABLE, columnName, definition); } await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(ITEM_TABLE)} MODIFY COLUMN listing_status ENUM('draft', 'pending', 'claimed', 'failed', 'listed') NOT NULL DEFAULT 'draft' COMMENT '上架状态'` ); const legacyItemIndexes = await planningDatabase.query( `SHOW INDEX FROM ${escapeIdentifier(ITEM_TABLE)} WHERE Key_name = 'uk_planning_selection_fission_item'` ); if (legacyItemIndexes[0]) { await planningDatabase.execute( `ALTER TABLE ${escapeIdentifier(ITEM_TABLE)} DROP INDEX uk_planning_selection_fission_item` ); } await ensureSelectionFissionTableIndex( ITEM_TABLE, 'uk_planning_selection_fission_item_variant', 'UNIQUE KEY uk_planning_selection_fission_item_variant (plan_id, market_item_id, variant_no)' ); await ensureSelectionFissionTableIndex( ITEM_TABLE, 'uk_planning_selection_fission_task_code', 'UNIQUE KEY uk_planning_selection_fission_task_code (task_code)' ); await ensureSelectionFissionTableIndex( ITEM_TABLE, 'idx_planning_selection_fission_item_listing', 'KEY idx_planning_selection_fission_item_listing (plan_id, listing_status, listed_item_id)' ); await ensureSelectionFissionTableIndex( ITEM_TABLE, 'idx_planning_selection_fission_item_selected', 'KEY idx_planning_selection_fission_item_selected (plan_id, selected_spu_code, listed_item_id)' ); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET source_item_id = id WHERE source_item_id IS NULL` ); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} i INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id SET i.task_code = CONCAT( p.scheme_code, '-S', LPAD(i.slot_no, 2, '0'), '-', LPAD(i.variant_no, 2, '0') ) WHERE i.task_code IS NULL OR i.task_code = ''` ); await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(PRICING_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '方案ID', plan_item_id BIGINT NOT NULL COMMENT '方案市场ID明细ID', sku_code VARCHAR(160) NOT NULL COMMENT 'SKU编码', sku_name VARCHAR(255) NULL COMMENT '公司原始SKU名称', ai_sku_name_candidates_json LONGTEXT NULL COMMENT 'AI生成的5个SKU名称', selected_sku_name VARCHAR(255) NULL COMMENT '用户选择的上架SKU名称', ai_sku_name_model VARCHAR(160) NULL COMMENT '生成SKU名称的模型', ai_sku_name_generated_at DATETIME NULL COMMENT 'AI名称生成时间', sku_image_url VARCHAR(2000) NULL COMMENT 'SKU图片', generated_image_url VARCHAR(2000) NULL COMMENT '场景替换后SKU图', product_source VARCHAR(160) NULL COMMENT '商品来源', operator_name VARCHAR(120) NULL COMMENT '操作人', operated_at DATETIME NULL COMMENT '操作时间', brand_name VARCHAR(160) NULL COMMENT '品牌名称', barcode VARCHAR(160) NULL COMMENT '条码', size_label VARCHAR(255) NULL COMMENT '尺寸', color_label VARCHAR(160) NULL COMMENT '颜色', cost_amount DECIMAL(18,2) NULL COMMENT '成本', competitor_price DECIMAL(18,2) NULL COMMENT '竞品售价', installation_fee DECIMAL(18,2) NULL COMMENT '安装费', freight_national DECIMAL(18,2) NULL COMMENT '全国运费', freight_zone1 DECIMAL(18,2) NULL COMMENT '一区运费', freight_zone2 DECIMAL(18,2) NULL COMMENT '二区运费', freight_zone3 DECIMAL(18,2) NULL COMMENT '三区运费', freight_zone4 DECIMAL(18,2) NULL COMMENT '四区运费', discount_amount DECIMAL(18,2) NULL COMMENT '优惠金额', activity_discount DECIMAL(10,4) NULL COMMENT '活动折扣', aftersale_ratio DECIMAL(10,4) NULL COMMENT '公司售后占比', promotion_ratio DECIMAL(10,4) NULL COMMENT '推广占比', gross_margin DECIMAL(10,4) NULL COMMENT '毛利率', platform_commission_ratio DECIMAL(10,4) NULL COMMENT '平台佣金', marketing_ad_ratio DECIMAL(10,4) NULL COMMENT '营销广告费占比', farming_ratio DECIMAL(10,4) NULL COMMENT '种菜占比', insurance_ratio DECIMAL(10,4) NULL COMMENT '保险费占比', brand_usage_ratio DECIMAL(10,4) NULL COMMENT '品牌使用费占比', estimated_fee_ratio DECIMAL(10,4) NULL COMMENT '预估费比', final_price DECIMAL(18,2) NULL COMMENT '确定售价', gross_profit DECIMAL(18,2) NULL COMMENT '销售毛利', operating_profit DECIMAL(18,2) NULL COMMENT '营业利润额', operating_profit_ratio DECIMAL(10,4) NULL COMMENT '营业利润率', minimum_price DECIMAL(18,2) NULL COMMENT '最低限价', listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', raw_payload_json LONGTEXT NULL COMMENT 'SKU/运费/图片生成原始数据', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_pricing_sku (plan_item_id, sku_code), KEY idx_planning_selection_pricing_plan (plan_id, plan_item_id), KEY idx_planning_selection_pricing_listing (listed_item_id), CONSTRAINT fk_planning_selection_pricing_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE, CONSTRAINT fk_planning_selection_pricing_item FOREIGN KEY (plan_item_id) REFERENCES ${escapeIdentifier(ITEM_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池裂变方案SKU定价表'` ); const pricingColumnDefinitions = [ ['ai_sku_name_candidates_json', "LONGTEXT NULL COMMENT 'AI生成的5个SKU名称' AFTER sku_name"], ['selected_sku_name', "VARCHAR(255) NULL COMMENT '用户选择的上架SKU名称' AFTER ai_sku_name_candidates_json"], ['ai_sku_name_model', "VARCHAR(160) NULL COMMENT '生成SKU名称的模型' AFTER selected_sku_name"], ['ai_sku_name_generated_at', "DATETIME NULL COMMENT 'AI名称生成时间' AFTER ai_sku_name_model"], ['product_source', "VARCHAR(160) NULL COMMENT '商品来源' AFTER generated_image_url"], ['operator_name', "VARCHAR(120) NULL COMMENT '操作人' AFTER product_source"], ['operated_at', "DATETIME NULL COMMENT '操作时间' AFTER operator_name"], ['brand_name', "VARCHAR(160) NULL COMMENT '品牌名称' AFTER operated_at"], ['barcode', "VARCHAR(160) NULL COMMENT '条码' AFTER brand_name"], ['size_label', "VARCHAR(255) NULL COMMENT '尺寸' AFTER barcode"], ['color_label', "VARCHAR(160) NULL COMMENT '颜色' AFTER size_label"], ['competitor_price', "DECIMAL(18,2) NULL COMMENT '竞品售价' AFTER cost_amount"], ['installation_fee', "DECIMAL(18,2) NULL COMMENT '安装费' AFTER competitor_price"], ['gross_margin', "DECIMAL(10,4) NULL COMMENT '毛利率' AFTER promotion_ratio"], ['platform_commission_ratio', "DECIMAL(10,4) NULL COMMENT '平台佣金' AFTER gross_margin"], ['marketing_ad_ratio', "DECIMAL(10,4) NULL COMMENT '营销广告费占比' AFTER platform_commission_ratio"], ['farming_ratio', "DECIMAL(10,4) NULL COMMENT '种菜占比' AFTER marketing_ad_ratio"], ['insurance_ratio', "DECIMAL(10,4) NULL COMMENT '保险费占比' AFTER farming_ratio"], ['brand_usage_ratio', "DECIMAL(10,4) NULL COMMENT '品牌使用费占比' AFTER insurance_ratio"], ['operating_profit', "DECIMAL(18,2) NULL COMMENT '营业利润额' AFTER gross_profit"], ['operating_profit_ratio', "DECIMAL(10,4) NULL COMMENT '营业利润率' AFTER operating_profit"], ['group_price', "DECIMAL(18,2) NULL COMMENT '自动上架SKU最终拼单价' AFTER final_price"], ['pricing_saved_at', "DATETIME NULL COMMENT '利润测算保存时间' AFTER group_price"], ['pricing_saved_by', "VARCHAR(120) NULL COMMENT '利润测算保存人' AFTER pricing_saved_at"], ['pricing_version', "INT NOT NULL DEFAULT 0 COMMENT '利润测算版本' AFTER pricing_saved_by"], ['internal_product_code', "VARCHAR(160) NULL COMMENT '公司内部SKU编码' AFTER pricing_version"], ['product_code_product_name', "VARCHAR(255) NULL COMMENT '申请编码时的SKU名称' AFTER internal_product_code"], ['product_code_color_spec', "VARCHAR(500) NULL COMMENT '公司编码返回的颜色规格' AFTER product_code_product_name"], ]; for (const [columnName, definition] of pricingColumnDefinitions) { await ensureSelectionFissionTableColumn(PRICING_TABLE, columnName, definition); } await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', plan_id BIGINT NOT NULL COMMENT '方案ID', plan_item_id BIGINT NOT NULL COMMENT '方案市场ID明细ID', sku_code VARCHAR(160) NOT NULL COMMENT 'SKU编码', product_source VARCHAR(160) NULL COMMENT '商品来源', product_image_url VARCHAR(2000) NULL COMMENT '商品图片', operator_name VARCHAR(120) NULL COMMENT '操作人', brand_name VARCHAR(160) NULL COMMENT '品牌名称', operated_at DATETIME NULL COMMENT '操作时间', barcode VARCHAR(160) NULL COMMENT '条码', size_label VARCHAR(255) NULL COMMENT '尺寸', color_label VARCHAR(160) NULL COMMENT '颜色', competitor_price DECIMAL(18,2) NULL COMMENT '竞品售价', landed_price DECIMAL(18,2) NULL COMMENT '定价到手价', coupon_amount DECIMAL(18,2) NULL COMMENT '优惠券金额', activity_discount DECIMAL(10,4) NULL COMMENT '活动折扣', group_price DECIMAL(18,2) NULL COMMENT '拼单价', final_group_price DECIMAL(18,2) NULL COMMENT '最终拼单价', listed_item_id VARCHAR(160) NULL COMMENT '上架后的商品ID', activity_prices_json LONGTEXT NULL COMMENT '各活动到手价明细', raw_payload_json LONGTEXT NULL COMMENT '定价来源快照', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_planning_selection_activity_sku (plan_item_id, sku_code), KEY idx_planning_selection_activity_plan (plan_id, plan_item_id), CONSTRAINT fk_planning_selection_activity_plan FOREIGN KEY (plan_id) REFERENCES ${escapeIdentifier(PLAN_TABLE)} (id) ON DELETE CASCADE, CONSTRAINT fk_planning_selection_activity_item FOREIGN KEY (plan_item_id) REFERENCES ${escapeIdentifier(ITEM_TABLE)} (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选品池拼多多活动定价表'` ); await ensureSelectionFissionTableColumn( ACTIVITY_PRICING_TABLE, 'listed_item_id', "VARCHAR(160) NULL COMMENT '上架后的商品ID' AFTER final_group_price" ); await ensureSelectionFissionTableIndex( ACTIVITY_PRICING_TABLE, 'idx_planning_selection_activity_listing', 'KEY idx_planning_selection_activity_listing (listed_item_id)' ); schemaReady = true; readSchemaReady = true; })().finally(() => { schemaPromise = null; }); await schemaPromise; } const SELECTION_FISSION_PLAN_LIST_COLUMNS = [ 'id', 'scheme_code', 'dingtalk_user_id', 'dingtalk_username', 'dingtalk_user_name', 'department_name', 'workflow_status', 'workflow_step', 'workflow_progress', 'workflow_error_message', 'workflow_started_at', 'workflow_completed_at', 'status', 'listing_shop_id', 'listing_shop_code', 'listing_shop_name', 'listing_shop_platform', 'claimed_by', 'claimed_at', 'claim_expires_at', 'claim_count', 'auto_listed_product_id', 'auto_listing_completed_at', 'auto_listing_failed_at', 'auto_listing_error_code', 'auto_listing_error_message', 'item_count', 'listed_item_count', 'created_by_user_id', 'created_by_username', 'created_at', 'updated_at', ]; const SELECTION_FISSION_ITEM_LIST_COLUMNS = [ 'id', 'plan_id', 'source_item_id', 'slot_no', 'variant_no', 'market_item_id', 'channel_name', 'title', 'main_image_url', 'selected_spu_code', 'workflow_step', 'workflow_status', 'workflow_error_message', 'listing_status', 'listed_item_id', 'listing_error_code', 'listing_error_message', 'pdd_draft_commit_id', ]; const SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS = { [PLAN_TABLE]: [ ...SELECTION_FISSION_PLAN_LIST_COLUMNS, 'listing_shop_snapshot_json', 'claim_token_hash', 'auto_listing_callback_json', ], [ITEM_TABLE]: [ ...SELECTION_FISSION_ITEM_LIST_COLUMNS, 'task_code', 'price_band_list_json', 'selected_spu_payload_json', 'selected_market_references_json', 'title_keyword_strategy_json', 'pdd_attribute_ai_result_json', 'pricing_table_version', 'product_code_completed_at', 'metadata_json', 'created_at', 'updated_at', ], [PRICING_TABLE]: [ 'id', 'plan_id', 'plan_item_id', 'sku_code', 'selected_sku_name', 'group_price', 'pricing_version', 'internal_product_code', 'product_code_color_spec', ], [ACTIVITY_PRICING_TABLE]: [ 'id', 'plan_id', 'plan_item_id', 'sku_code', 'listed_item_id', 'activity_prices_json', ], [LISTING_RESULT_TABLE]: ['id', 'plan_id', 'plan_item_id', 'task_code', 'product_id'], [CALLBACK_HISTORY_TABLE]: ['id', 'plan_id', 'plan_item_id', 'callback_type'], [REVISION_HISTORY_TABLE]: ['id', 'plan_id', 'plan_item_id', 'snapshot_json'], [USER_SHOP_TABLE]: ['id', 'dingtalk_user_name', 'shop_id', 'last_seen_at'], }; async function isSelectionFissionSchemaCurrent() { try { const tableNames = Object.keys(SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS); const rows = await planningDatabase.query( `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${tableNames.map(() => '?').join(', ')})`, tableNames, { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } ); const columnsByTable = new Map(); for (const row of rows) { const columns = columnsByTable.get(row.table_name) || new Set(); columns.add(row.column_name); columnsByTable.set(row.table_name, columns); } return Object.entries(SELECTION_FISSION_SCHEMA_COLUMN_SENTINELS).every(([ tableName, requiredColumns, ]) => { const columns = columnsByTable.get(tableName); return columns && requiredColumns.every((columnName) => columns.has(columnName)); }); } catch (_error) { return false; } } async function ensureSelectionFissionReadSchema() { if (schemaReady || readSchemaReady) return; if (readSchemaPromise) { await readSchemaPromise; return; } readSchemaPromise = (async () => { try { const [planColumns, itemColumns] = await Promise.all([ planningDatabase.query(`SHOW COLUMNS FROM ${escapeIdentifier(PLAN_TABLE)}`), planningDatabase.query(`SHOW COLUMNS FROM ${escapeIdentifier(ITEM_TABLE)}`), ]); const planColumnNames = new Set(planColumns.map((column) => column.Field)); const itemColumnNames = new Set(itemColumns.map((column) => column.Field)); const hasRequiredColumns = ( SELECTION_FISSION_PLAN_LIST_COLUMNS.every((column) => planColumnNames.has(column)) && SELECTION_FISSION_ITEM_LIST_COLUMNS.every((column) => itemColumnNames.has(column)) ); if (hasRequiredColumns) { readSchemaReady = true; return; } } catch (_error) { // A missing table is handled by the full migration below. } await ensureSelectionFissionSchema(); readSchemaReady = true; })().finally(() => { readSchemaPromise = null; }); await readSchemaPromise; } function parseFirstJsonObject(text) { const raw = String(text || '').trim(); if (!raw) return null; const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidates = [fenced?.[1], raw].filter(Boolean); for (const candidate of candidates) { try { return JSON.parse(candidate); } catch (_error) { const start = candidate.indexOf('{'); const end = candidate.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(candidate.slice(start, end + 1)); } catch (_nestedError) { // Keep trying other candidates. } } } } return null; } function extractUrls(text) { return Array.from(new Set( String(text || '').match(/https?:\/\/[^\s)"'<>]+/g) || [] )); } function extractFirstImageUrl(text) { return extractUrls(text).find((url) => /\.(?:jpg|jpeg|png|webp|avif)(?:[?#]|$)/i.test(url)) || ''; } function normalizeMarketItemLookupId(value) { return normalizeText(value, 160).replace(/^[a-z][a-z0-9_-]*:/i, ''); } async function loadCanonicalMarketPriceBands(items = []) { const lookupIds = Array.from(new Set(items.flatMap((item) => { const original = normalizeText(item?.marketItemId || item?.itemId || item, 160); const normalized = normalizeMarketItemLookupId(original); return [original, normalized].filter(Boolean); }))); if (lookupIds.length === 0) return new Map(); const rows = await planningDatabase.query( `SELECT item_id AS itemId, price_band AS priceBand FROM ${escapeIdentifier(MARKET_RANK_TABLE)} WHERE item_id IN (${lookupIds.map(() => '?').join(', ')}) AND price_band IS NOT NULL AND TRIM(price_band) <> ''`, lookupIds ); const result = new Map(); for (const row of rows) { const keys = [normalizeText(row.itemId, 160), normalizeMarketItemLookupId(row.itemId)].filter(Boolean); for (const key of keys) { result.set(key, normalizePriceBandList(result.get(key), row.priceBand)); } } for (const [key, values] of result.entries()) { result.set(key, sortPriceBandValues(values)); } return result; } async function applyCanonicalMarketPriceBands(items = []) { if (!Array.isArray(items) || items.length === 0) return []; let canonicalByItemId = new Map(); try { canonicalByItemId = await loadCanonicalMarketPriceBands(items); } catch (error) { console.warn('读取榜单价格带统一口径失败,保留方案已存价格带:', error.message); } return items.map((item) => { const originalId = normalizeText(item.marketItemId || item.itemId, 160); const normalizedId = normalizeMarketItemLookupId(originalId); const canonical = canonicalByItemId.get(originalId) || canonicalByItemId.get(normalizedId) || []; const fallback = normalizePriceBandList(item.priceBandList, item.priceBand); const priceBandList = canonical.length > 0 ? canonical : fallback; return { ...item, priceBand: priceBandList[0] || '', priceBandList, priceBandSource: canonical.length > 0 ? MARKET_RANK_TABLE : 'stored_fallback', }; }); } async function fetchMarketProfileFromLocalIndex(item) { const marketItemId = normalizeText(item.marketItemId || item.market_item_id, 160); const lookupId = normalizeMarketItemLookupId(marketItemId); if (!lookupId) return null; const rows = await planningDatabase.query( `SELECT item_id, requested_item_id, source_channel, title, main_image_url, source_table, source_record_id FROM ${escapeIdentifier(MARKET_IMAGE_INDEX_TABLE)} WHERE item_id = ? OR requested_item_id = ? OR requested_item_id = ? ORDER BY indexed_status = 'success' DESC, indexed_at DESC, id DESC LIMIT 1`, [lookupId, marketItemId, lookupId] ); const row = rows[0]; const mainImageUrl = normalizeImageUrl(row?.main_image_url); if (!row || !mainImageUrl) return null; return { title: normalizeText(row.title, 500), shopName: '', mainImageUrl, detailUrl: '', skuImageUrls: [], source: 'local-market-image-index', raw: { itemId: normalizeText(row.item_id, 160), requestedItemId: normalizeText(row.requested_item_id, 160), sourceChannel: normalizeText(row.source_channel, 80), sourceTable: normalizeText(row.source_table, 160), sourceRecordId: row.source_record_id || null, }, }; } async function fetchMarketProfile(item) { const profile = await fetchMarketProfileFromLocalIndex(item); if (profile?.mainImageUrl) return profile; throw createExternalServiceError('本地商品索引没有主图,请提供商品主图', 502, 'MARKET_PROFILE_EMPTY'); } async function refreshPlanItemMarketProfile(planItemId, user = {}) { await ensureSelectionFissionSchema(); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ITEM_TABLE)} WHERE id = ? LIMIT 1`, [planItemId] ); const item = rows[0]; if (!item) return null; try { const profile = await fetchMarketProfile(item, user); if (!profile) return null; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET title = COALESCE(NULLIF(?, ''), title), shop_name = COALESCE(NULLIF(?, ''), shop_name), main_image_url = COALESCE(NULLIF(?, ''), main_image_url), market_detail_url = COALESCE(NULLIF(?, ''), market_detail_url), market_profile_json = ?, updated_at = NOW() WHERE id = ?`, [ profile.title || '', profile.shopName || '', profile.mainImageUrl || '', profile.detailUrl || '', safeJsonStringify(profile), planItemId, ] ); return profile; } catch (error) { return { warning: error.message || '市场ID详情补充失败', code: error.code || 'MARKET_PROFILE_FAILED', }; } } function mapPricingRow(row = {}) { const originalSkuName = normalizeText(row.sku_name || row.originalSkuName || row.skuName); const selectedSkuName = normalizeText(row.selected_sku_name || row.selectedSkuName, 255); const optimizedSkuNames = safeJsonParse( row.ai_sku_name_candidates_json || row.aiSkuNameCandidatesJson || row.optimizedSkuNames, [] ); return { id: row.id, planId: row.plan_id || row.planId, planItemId: row.plan_item_id || row.planItemId, skuCode: normalizeText(row.sku_code || row.skuCode), skuName: selectedSkuName || originalSkuName, originalSkuName, selectedSkuName, optimizedSkuNames: Array.isArray(optimizedSkuNames) ? optimizedSkuNames.map((name) => normalizeText(name, 120)).filter(Boolean) : [], aiSkuNameModel: normalizeText(row.ai_sku_name_model || row.aiSkuNameModel, 160), aiSkuNameGeneratedAt: row.ai_sku_name_generated_at || row.aiSkuNameGeneratedAt || null, skuImageUrl: normalizeImageUrl(row.sku_image_url || row.skuImageUrl), generatedImageUrl: normalizeImageUrl(row.generated_image_url || row.generatedImageUrl), productSource: normalizeText(row.product_source || row.productSource, 160), operatorName: normalizeText(row.operator_name || row.operatorName, 120), operatedAt: row.operated_at || row.operatedAt || null, brandName: normalizeText(row.brand_name || row.brandName, 160), barcode: normalizeText(row.barcode, 160), size: normalizeText(row.size_label || row.size, 255), color: normalizeText(row.color_label || row.color, 160), cost: normalizeDecimal(row.cost_amount ?? row.cost), competitorPrice: normalizeDecimal(row.competitor_price ?? row.competitorPrice), installationFee: normalizeDecimal(row.installation_fee ?? row.installationFee), freightNational: normalizeDecimal(row.freight_national ?? row.freightNational), freightZone1: normalizeDecimal(row.freight_zone1 ?? row.freightZone1), freightZone2: normalizeDecimal(row.freight_zone2 ?? row.freightZone2), freightZone3: normalizeDecimal(row.freight_zone3 ?? row.freightZone3), freightZone4: normalizeDecimal(row.freight_zone4 ?? row.freightZone4), discountAmount: normalizeDecimal(row.discount_amount ?? row.discountAmount), activityDiscount: normalizeDecimal(row.activity_discount ?? row.activityDiscount), aftersaleRatio: normalizeDecimal(row.aftersale_ratio ?? row.aftersaleRatio), promotionRatio: normalizeDecimal(row.promotion_ratio ?? row.promotionRatio), grossMargin: normalizeDecimal(row.gross_margin ?? row.grossMargin), platformCommissionRatio: normalizeDecimal(row.platform_commission_ratio ?? row.platformCommissionRatio), marketingAdRatio: normalizeDecimal(row.marketing_ad_ratio ?? row.marketingAdRatio), farmingRatio: normalizeDecimal(row.farming_ratio ?? row.farmingRatio), insuranceRatio: normalizeDecimal(row.insurance_ratio ?? row.insuranceRatio), brandUsageRatio: normalizeDecimal(row.brand_usage_ratio ?? row.brandUsageRatio), estimatedFeeRatio: normalizeDecimal(row.estimated_fee_ratio ?? row.estimatedFeeRatio), finalPrice: normalizeDecimal(row.final_price ?? row.finalPrice), grossProfit: normalizeDecimal(row.gross_profit ?? row.grossProfit), operatingProfit: normalizeDecimal(row.operating_profit ?? row.operatingProfit), operatingProfitRatio: normalizeDecimal(row.operating_profit_ratio ?? row.operatingProfitRatio), groupPrice: normalizeDecimal(row.group_price ?? row.groupPrice ?? row.final_price ?? row.finalPrice), pricingSavedAt: row.pricing_saved_at || row.pricingSavedAt || null, pricingSavedBy: normalizeText(row.pricing_saved_by || row.pricingSavedBy, 120), pricingVersion: Number(row.pricing_version || row.pricingVersion || 0), internalProductCode: normalizeText(row.internal_product_code || row.internalProductCode, 160), productCodeProductName: normalizeText( row.product_code_product_name || row.productCodeProductName, 255 ), productCodeColorSpec: normalizeText( row.product_code_color_spec || row.productCodeColorSpec, 500 ), minimumPrice: normalizeDecimal(row.minimum_price ?? row.minimumPrice), listedItemId: normalizeText(row.listed_item_id || row.listedItemId), rawPayload: safeJsonParse(row.raw_payload_json || row.rawPayloadJson, null), createdAt: row.created_at || row.createdAt, updatedAt: row.updated_at || row.updatedAt, }; } function mapActivityPricingRow(row = {}) { const activityPrices = safeJsonParse( row.activity_prices_json || row.activityPricesJson || row.activityPrices, {} ); return { id: row.id, planId: row.plan_id || row.planId, planItemId: row.plan_item_id || row.planItemId, skuCode: normalizeText(row.sku_code || row.skuCode, 160), productSource: normalizeText(row.product_source || row.productSource, 160), productImageUrl: normalizeImageUrl(row.product_image_url || row.productImageUrl), operatorName: normalizeText(row.operator_name || row.operatorName, 120), brandName: normalizeText(row.brand_name || row.brandName, 160), operatedAt: row.operated_at || row.operatedAt || null, barcode: normalizeText(row.barcode, 160), size: normalizeText(row.size_label || row.size, 255), color: normalizeText(row.color_label || row.color, 160), competitorPrice: normalizeDecimal(row.competitor_price ?? row.competitorPrice), landedPrice: normalizeDecimal(row.landed_price ?? row.landedPrice), couponAmount: normalizeDecimal(row.coupon_amount ?? row.couponAmount), activityDiscount: normalizeDecimal(row.activity_discount ?? row.activityDiscount), groupPrice: normalizeDecimal(row.group_price ?? row.groupPrice), finalGroupPrice: normalizeDecimal(row.final_group_price ?? row.finalGroupPrice), listedItemId: normalizeText(row.listed_item_id || row.listedItemId, 160), activityPrices: activityPrices && typeof activityPrices === 'object' ? activityPrices : {}, rawPayload: safeJsonParse(row.raw_payload_json || row.rawPayloadJson, null), createdAt: row.created_at || row.createdAt, updatedAt: row.updated_at || row.updatedAt, }; } function getCurrentTitleKeywordStrategy(task = {}) { const strategy = task.titleKeywordStrategy; if (!strategy || typeof strategy !== 'object') return null; const currentReferenceMode = normalizeAlibaba1688MainImageMode( task.mainImageGeneration?.mode, 'source_original' ); return normalizeText(strategy.referenceMode, 32) === currentReferenceMode ? strategy : null; } function mapPlanItem(row = {}) { const metadata = safeJsonParse(row.metadata_json || row.metadataJson, null); const mainImageGeneration = safeJsonParse( row.main_image_generation_json || row.mainImageGenerationJson, null ); const storedTitleKeywordStrategy = safeJsonParse( row.title_keyword_strategy_json || row.titleKeywordStrategyJson, null ); const titleKeywordStrategy = getCurrentTitleKeywordStrategy({ mainImageGeneration, titleKeywordStrategy: storedTitleKeywordStrategy, }); return { id: row.id, planId: row.plan_id || row.planId, sourceItemId: Number(row.source_item_id || row.sourceItemId || row.id || 0), slotNo: Number(row.slot_no || row.slotNo || 0), variantNo: Number(row.variant_no || row.variantNo || 1), taskCode: normalizeText(row.task_code || row.taskCode, 96), marketItemId: normalizeText(row.market_item_id || row.marketItemId), channelName: normalizeText(row.channel_name || row.channelName), title: normalizeText(row.title), shopName: normalizeText(row.shop_name || row.shopName), categoryLevel1: normalizeText(row.category_level1 || row.categoryLevel1), categoryLevel2: normalizeText(row.category_level2 || row.categoryLevel2), categoryLevel3: normalizeText(row.category_level3 || row.categoryLevel3), mainImageUrl: normalizeImageUrl(row.main_image_url || row.mainImageUrl), marketDetailUrl: normalizeText(row.market_detail_url || row.marketDetailUrl), priceBand: normalizeText(row.price_band || row.priceBand, 120), priceBandList: normalizePriceBandList( row.price_band_list_json || row.priceBandListJson || row.priceBandList, row.price_band || row.priceBand ), replacedImageUrl: normalizeImageUrl(row.replaced_image_url || row.replacedImageUrl), selectedSpuCode: normalizeText(row.selected_spu_code || row.selectedSpuCode), selectedSpuName: normalizeText(row.selected_spu_name || row.selectedSpuName), selectedSpuPayload: safeJsonParse(row.selected_spu_payload_json || row.selectedSpuPayloadJson, null), marketReferenceBatch: safeJsonParse( row.market_reference_batch_json || row.marketReferenceBatchJson, null ), selectedMarketReferences: safeJsonParse( row.selected_market_references_json || row.selectedMarketReferencesJson, [] ), whiteBackgroundAssets: safeJsonParse( row.white_background_assets_json || row.whiteBackgroundAssetsJson, null ), selectedMainSkuCode: normalizeText(row.selected_main_sku_code || row.selectedMainSkuCode, 160), selectedMainSkuName: normalizeText(row.selected_main_sku_name || row.selectedMainSkuName, 255), selectedMainSkuImageUrl: normalizeImageUrl( row.selected_main_sku_image_url || row.selectedMainSkuImageUrl ), mainImageGeneration, skuSceneImages: safeJsonParse(row.sku_scene_images_json || row.skuSceneImagesJson, null), skuNameCandidates: safeJsonParse( row.sku_name_candidates_json || row.skuNameCandidatesJson, null ), titleKeywordStrategy, titleCandidatesStale: Boolean(storedTitleKeywordStrategy && !titleKeywordStrategy), optimizedTitleCandidates: safeJsonParse( row.optimized_title_candidates_json || row.optimizedTitleCandidatesJson, [] ), selectedProductTitle: normalizeText( row.selected_product_title || row.selectedProductTitle, 500 ), titleAiModel: normalizeText(row.title_ai_model || row.titleAiModel, 160), titleAiGeneratedAt: row.title_ai_generated_at || row.titleAiGeneratedAt || null, workflowStep: normalizeWorkflowStep(row.workflow_step || row.workflowStep || 0), workflowStatus: normalizeText(row.workflow_status || row.workflowStatus || 'draft', 20), workflowErrorMessage: normalizeText( row.workflow_error_message || row.workflowErrorMessage, 1000 ), listedItemId: normalizeText(row.listed_item_id || row.listedItemId), listingStatus: normalizeText(row.listing_status || row.listingStatus || 'draft'), claimedBy: normalizeText(row.claimed_by || row.claimedBy, 160), claimedAt: row.claimed_at || row.claimedAt || null, claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null, claimCount: Number(row.claim_count || row.claimCount || 0), listingErrorCode: normalizeText(row.listing_error_code || row.listingErrorCode, 120), listingErrorMessage: normalizeText( row.listing_error_message || row.listingErrorMessage, 1000 ), listingCompletedAt: row.listing_completed_at || row.listingCompletedAt || null, pddDraftCommitId: normalizeText(row.pdd_draft_commit_id || row.pddDraftCommitId, 160), pddDraftConfig: safeJsonParse( row.pdd_draft_config_json || row.pddDraftConfigJson || row.pddDraftConfig, null ), pddDraftRequestHash: normalizeText( row.pdd_draft_request_hash || row.pddDraftRequestHash, 64 ), pddDraftRequest: safeJsonParse( row.pdd_draft_request_json || row.pddDraftRequestJson || row.pddDraftRequest, null ), pddDraftResponse: safeJsonParse( row.pdd_draft_response_json || row.pddDraftResponseJson || row.pddDraftResponse, null ), pddAttributeAiProgress: safeJsonParse( row.pdd_attribute_ai_progress_json || row.pddAttributeAiProgressJson || row.pddAttributeAiProgress, null ), pddAttributeAiResult: safeJsonParse( row.pdd_attribute_ai_result_json || row.pddAttributeAiResultJson || row.pddAttributeAiResult, null ), pricingTableSavedAt: row.pricing_table_saved_at || row.pricingTableSavedAt || null, pricingTableSavedBy: normalizeText(row.pricing_table_saved_by || row.pricingTableSavedBy, 120), pricingTableVersion: Number(row.pricing_table_version || row.pricingTableVersion || 0), productCodeStatus: normalizeText( row.product_code_status || row.productCodeStatus || 'not_requested', 32 ), productCodeStyleCode: normalizeText( row.product_code_style_code || row.productCodeStyleCode, 160 ), productCodeRequestHash: normalizeText( row.product_code_request_hash || row.productCodeRequestHash, 64 ), productCodeConfig: safeJsonParse( row.product_code_config_json || row.productCodeConfigJson || row.productCodeConfig, null ), productCodeRequest: safeJsonParse( row.product_code_request_json || row.productCodeRequestJson || row.productCodeRequest, null ), productCodeResponse: safeJsonParse( row.product_code_response_json || row.productCodeResponseJson || row.productCodeResponse, null ), productCodeErrorMessage: normalizeText( row.product_code_error_message || row.productCodeErrorMessage, 1000 ), productCodeRequestedAt: row.product_code_requested_at || row.productCodeRequestedAt || null, productCodeCompletedAt: row.product_code_completed_at || row.productCodeCompletedAt || null, similarResponse: safeJsonParse(row.similar_response_json || row.similarResponseJson, null), skuSnapshot: safeJsonParse(row.sku_snapshot_json || row.skuSnapshotJson, null), videoAssets: safeJsonParse(row.video_assets_json || row.videoAssetsJson, null), freightProfile: safeJsonParse(row.freight_profile_json || row.freightProfileJson, null), marketProfile: safeJsonParse(row.market_profile_json || row.marketProfileJson, null), marketEvidence: metadata?.marketEvidence || null, supplyDecision: metadata?.supplyDecision || null, metadata, pricingRows: [], activityPricingRows: [], createdAt: row.created_at || row.createdAt, updatedAt: row.updated_at || row.updatedAt, }; } function mapPlan(row = {}) { const autoListedProductId = normalizeText( row.auto_listed_product_id || row.autoListedProductId || row.product_id || row.productId, 160 ); const storedStatus = normalizeText(row.status, 20); const status = autoListedProductId ? 'listed' : (['pending', 'claimed', 'failed'].includes(storedStatus) ? storedStatus : 'pending'); const statusMeta = { pending: { label: '自动上架中', code: 'processing' }, claimed: { label: '自动上架中', code: 'processing' }, failed: { label: '上架失败', code: 'failed' }, listed: { label: '上架完成', code: 'completed' }, }[status]; const listingShop = { shopId: normalizeText(row.listing_shop_id || row.listingShopId, 160), shopCode: normalizeText(row.listing_shop_code || row.listingShopCode, 160), shopName: normalizeText(row.listing_shop_name || row.listingShopName, 255), platform: normalizeText(row.listing_shop_platform || row.listingShopPlatform, 80), }; return { id: row.id, schemeCode: normalizeText(row.scheme_code || row.schemeCode), dingtalkUserId: normalizeText(row.dingtalk_user_id || row.dingtalkUserId), dingtalkUsername: normalizeText(row.dingtalk_username || row.dingtalkUsername), dingtalkUserName: normalizeText(row.dingtalk_user_name || row.dingtalkUserName), departmentName: normalizeText(row.department_name || row.departmentName), workflowStatus: normalizeText(row.workflow_status || row.workflowStatus || 'draft', 20), workflowStep: normalizeWorkflowStep(row.workflow_step || row.workflowStep || 0), workflowProgress: normalizeDecimal(row.workflow_progress ?? row.workflowProgress) || 0, workflowErrorMessage: normalizeText( row.workflow_error_message || row.workflowErrorMessage, 1000 ), workflowStartedAt: row.workflow_started_at || row.workflowStartedAt || null, workflowCompletedAt: row.workflow_completed_at || row.workflowCompletedAt || null, status, autoListingStatus: statusMeta.label, autoListingStatusCode: statusMeta.code, listingShopId: listingShop.shopId, listingShopCode: listingShop.shopCode, listingShopName: listingShop.shopName, listingShopPlatform: listingShop.platform, listingShop: listingShop.shopId || listingShop.shopName ? listingShop : null, listingShopSnapshot: safeJsonParse(row.listing_shop_snapshot_json || row.listingShopSnapshotJson, null), claimedBy: normalizeText(row.claimed_by || row.claimedBy, 160), claimedAt: row.claimed_at || row.claimedAt || null, claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null, claimCount: Number(row.claim_count || row.claimCount || 0), autoListedProductId, autoListingCompletedAt: row.auto_listing_completed_at || row.autoListingCompletedAt || null, autoListingFailedAt: row.auto_listing_failed_at || row.autoListingFailedAt || null, autoListingErrorCode: normalizeText(row.auto_listing_error_code || row.autoListingErrorCode, 120), autoListingErrorMessage: normalizeText(row.auto_listing_error_message || row.autoListingErrorMessage, 1000), itemCount: Number(row.item_count || row.itemCount || 0), listedItemCount: Number(row.listed_item_count || row.listedItemCount || 0), createdByUserId: normalizeText(row.created_by_user_id || row.createdByUserId), createdByUsername: normalizeText(row.created_by_username || row.createdByUsername), createdAt: row.created_at || row.createdAt, updatedAt: row.updated_at || row.updatedAt, items: [], }; } function getAutoListingStatusMeta(status) { return { draft: { label: '尚未提交自动上架', code: 'draft' }, pending: { label: '等待自动上架', code: 'pending' }, claimed: { label: '自动上架中', code: 'processing' }, failed: { label: '上架失败', code: 'failed' }, listed: { label: '上架完成', code: 'completed' }, }[status] || { label: '尚未提交自动上架', code: 'draft' }; } function buildWorkflowSummary(plan = {}) { const items = Array.isArray(plan.items) ? plan.items : []; const grouped = new Map(); for (const item of items) { const sourceId = Number(item.sourceItemId || item.id); const bucket = grouped.get(sourceId) || []; bucket.push(item); grouped.set(sourceId, bucket); } const sourceItems = Array.from(grouped.values()) .map((bucket) => bucket .slice() .sort((left, right) => left.variantNo - right.variantNo)[0]) .sort((left, right) => left.slotNo - right.slotNo); const listingTasks = items .filter((item) => item.selectedSpuCode) .sort((left, right) => ( left.slotNo - right.slotNo || left.variantNo - right.variantNo )); const completedTasks = listingTasks.filter((item) => item.workflowStatus === 'completed').length; let summaryStatus = plan.workflowStatus || 'draft'; let summaryStep = normalizeWorkflowStep(plan.workflowStep || 0); let overallProgress = Math.min(Math.max(Number(plan.workflowProgress || 0), 0), 100); const failedItem = items.find((item) => item.workflowStatus === 'failed'); if (failedItem) { summaryStatus = 'failed'; summaryStep = Math.max(1, Number(failedItem.workflowStep) || 1); } else if (['running', 'waiting'].includes(summaryStatus)) { const incompleteStates = items .map((item) => getSelectionFissionItemNodeState(item)) .filter((state) => !state.completed && state.step); const earliestIncompleteStep = incompleteStates .slice() .sort((left, right) => getWorkflowStepRank(left.step) - getWorkflowStepRank(right.step))[0]?.step || 0; if (earliestIncompleteStep > 0 && getWorkflowStepRank(earliestIncompleteStep) < getWorkflowStepRank(summaryStep)) { summaryStep = earliestIncompleteStep; summaryStatus = items.some((item) => item.workflowStatus === 'running') ? 'running' : 'waiting'; const stepDefinition = getWorkflowStepDefinition(summaryStep); overallProgress = Math.min(overallProgress, stepDefinition?.startProgress || overallProgress); } } const currentDefinition = getWorkflowStepDefinition(summaryStep); const currentStepRank = getWorkflowStepRank(summaryStep); const steps = WORKFLOW_STEP_DEFINITIONS.map((definition) => { const isCompleted = getWorkflowStepRank(definition.step) < currentStepRank || summaryStatus === 'completed'; const isCurrent = summaryStep === definition.step; const span = Math.max(definition.endProgress - definition.startProgress, 1); const progress = isCompleted ? 100 : isCurrent ? Math.round(Math.min(Math.max( ((overallProgress - definition.startProgress) / span) * 100, 0 ), 100)) : 0; return { ...definition, progress, status: isCompleted ? 'completed' : isCurrent ? summaryStatus : 'pending', }; }); return { status: summaryStatus, currentStep: summaryStep, currentStepKey: currentDefinition?.key || 'draft', currentStepLabel: currentDefinition?.label || '待开始裂变', progress: overallProgress, errorMessage: failedItem?.workflowErrorMessage || plan.workflowErrorMessage || '', sourceTotal: sourceItems.length, selectedSourceTotal: sourceItems.filter((source) => ( listingTasks.some((task) => task.sourceItemId === source.sourceItemId) )).length, taskTotal: listingTasks.length, completedTaskTotal: completedTasks, requiresUser: Boolean(currentDefinition?.requiresUser && summaryStatus === 'waiting'), steps, }; } async function attachPlanChildren(plans) { const planIds = plans.map((plan) => plan.id).filter(Boolean); if (planIds.length === 0) return plans; const placeholders = planIds.map(() => '?').join(', '); const itemRows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ITEM_TABLE)} WHERE plan_id IN (${placeholders}) ORDER BY plan_id DESC, slot_no ASC, id ASC`, planIds ); const items = await applyCanonicalMarketPriceBands(itemRows.map(mapPlanItem)); const itemIds = items.map((item) => item.id).filter(Boolean); const activityItemIds = items .filter((item) => getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) .map((item) => item.id) .filter(Boolean); const pricingByItemId = new Map(); const activityPricingByItemId = new Map(); if (itemIds.length > 0) { const itemPlaceholders = itemIds.map(() => '?').join(', '); const [pricingRows, activityPricingRows] = await Promise.all([ planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id IN (${itemPlaceholders}) ORDER BY plan_item_id ASC, sku_code ASC, id ASC`, itemIds ), activityItemIds.length > 0 ? planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} WHERE plan_item_id IN (${activityItemIds.map(() => '?').join(', ')}) ORDER BY plan_item_id ASC, sku_code ASC, id ASC`, activityItemIds ) : Promise.resolve([]), ]); for (const row of pricingRows.map(mapPricingRow)) { const bucket = pricingByItemId.get(row.planItemId) || []; bucket.push(row); pricingByItemId.set(row.planItemId, bucket); } for (const row of activityPricingRows.map(mapActivityPricingRow)) { const bucket = activityPricingByItemId.get(row.planItemId) || []; bucket.push(row); activityPricingByItemId.set(row.planItemId, bucket); } } const itemsByPlanId = new Map(); for (const item of items) { item.pricingRows = pricingByItemId.get(item.id) || []; item.activityPricingRows = activityPricingByItemId.get(item.id) || []; item.sourceReadiness = buildAlibaba1688SourceReadiness(item); item.pricingEvidence = buildAlibaba1688PricingEvidence(item); const bucket = itemsByPlanId.get(item.planId) || []; bucket.push(item); itemsByPlanId.set(item.planId, bucket); } return plans.map((plan) => { const nextPlan = { ...plan, items: itemsByPlanId.get(plan.id) || [], }; const workflow = buildWorkflowSummary(nextPlan); const listingTasks = nextPlan.items.filter((item) => item.selectedSpuCode); const listingStatuses = listingTasks.map((item) => ( item.listedItemId ? 'listed' : item.listingStatus )); let status = 'draft'; if (listingStatuses.length > 0 && listingStatuses.every((value) => value === 'listed')) { status = 'listed'; } else if (listingStatuses.includes('claimed')) { status = 'claimed'; } else if (listingStatuses.includes('failed')) { status = 'failed'; } else if (listingStatuses.includes('pending')) { status = 'pending'; } const statusMeta = getAutoListingStatusMeta(status); const workflowProfile = getSelectionWorkflowProfile(nextPlan.items[0] || {}); for (const item of nextPlan.items) { item.listingCompliance = buildAlibaba1688ListingCompliance(item, nextPlan); } return { ...nextPlan, workflowProfile, workflow, workflowStatus: workflow.status, workflowStep: workflow.currentStep, workflowProgress: workflow.progress, sourceItems: Array.from(new Map(nextPlan.items.map((item) => [ item.sourceItemId, nextPlan.items .filter((candidate) => candidate.sourceItemId === item.sourceItemId) .sort((left, right) => left.variantNo - right.variantNo)[0], ])).values()).sort((left, right) => left.slotNo - right.slotNo), listingTasks, status, autoListingStatus: statusMeta.label, autoListingStatusCode: statusMeta.code, autoListedProductIds: listingTasks.map((item) => item.listedItemId).filter(Boolean), itemCount: workflow.sourceTotal, listingTaskCount: workflow.taskTotal, listedItemCount: listingTasks.filter((item) => item.listedItemId).length, }; }); } async function recomputePlanListedState(planId) { const rows = await planningDatabase.query( `SELECT p.status, COUNT(DISTINCT i.source_item_id) AS item_count, SUM(CASE WHEN NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL THEN 1 ELSE 0 END) AS task_count, SUM(CASE WHEN NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL AND (i.listing_status = 'listed' OR NULLIF(TRIM(i.listed_item_id), '') IS NOT NULL) THEN 1 ELSE 0 END) AS listed_item_count, SUM(CASE WHEN i.listing_status = 'claimed' THEN 1 ELSE 0 END) AS claimed_count, SUM(CASE WHEN i.listing_status = 'failed' THEN 1 ELSE 0 END) AS failed_count, SUM(CASE WHEN i.listing_status = 'pending' THEN 1 ELSE 0 END) AS pending_count FROM ${escapeIdentifier(PLAN_TABLE)} p LEFT JOIN ${escapeIdentifier(ITEM_TABLE)} i ON i.plan_id = p.id WHERE p.id = ? GROUP BY p.id, p.status`, [planId] ); const summary = rows[0] || {}; const itemCount = Number(summary.item_count || 0); const taskCount = Number(summary.task_count || 0); const listedItemCount = Number(summary.listed_item_count || 0); const status = taskCount > 0 && listedItemCount === taskCount ? 'listed' : Number(summary.claimed_count || 0) > 0 ? 'claimed' : Number(summary.failed_count || 0) > 0 ? 'failed' : 'pending'; await planningDatabase.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET item_count = ?, listed_item_count = ?, status = ?, auto_listed_product_id = NULL, updated_at = NOW() WHERE id = ?`, [itemCount, listedItemCount, status, planId] ); return { itemCount, taskCount, listedItemCount, status }; } function normalizePlanInputItem(raw = {}, index = 0) { const marketItemId = normalizeText(raw.marketItemId || raw.itemId || raw.market_item_id, 160); if (!marketItemId) { const error = new Error(`第 ${index + 1} 个选品缺少市场ID`); error.status = 400; throw error; } const sourceRow = raw.sourceRow && typeof raw.sourceRow === 'object' ? raw.sourceRow : {}; const priceBandList = normalizePriceBandList( raw.priceBandList, raw.price_band_list, raw.priceBand, raw.price_band, sourceRow.priceBandList, sourceRow.priceBand ); return { slotNo: index + 1, marketItemId, channelName: normalizeText(raw.channelName || raw.channel || raw.platform, 80), title: normalizeText(raw.title || raw.productName || raw.name, 500), shopName: normalizeText(raw.shopName || raw.shop_name, 255), categoryLevel1: normalizeText(raw.categoryLevel1 || raw.category_level1, 255), categoryLevel2: normalizeText(raw.categoryLevel2 || raw.category_level2, 255), categoryLevel3: normalizeText(raw.categoryLevel3 || raw.category_level3, 255), mainImageUrl: normalizeImageUrl(raw.mainImageUrl || raw.imageUrl || raw.main_image_url), marketDetailUrl: normalizeText(raw.marketDetailUrl || raw.detailUrl || raw.url, 2000), priceBand: priceBandList[0] || '', priceBandList, metadata: raw, }; } const ALIBABA_1688_PENDING_PROFILE = 'alibaba_1688_pending'; function getSelectionWorkflowProfile(value = {}) { const metadata = value?.metadata && typeof value.metadata === 'object' ? value.metadata : value; const sourceRow = metadata?.sourceRow && typeof metadata.sourceRow === 'object' ? metadata.sourceRow : {}; return normalizeText( value?.workflowProfile || metadata?.workflowProfile || sourceRow?.workflowProfile || (sourceRow?.source === 'alibaba_1688_products' ? ALIBABA_1688_PENDING_PROFILE : ''), 80 ); } function getWorkflowProductTitleOptions(item = {}) { const candidates = Array.isArray(item.optimizedTitleCandidates) ? item.optimizedTitleCandidates.map((title) => normalizeText(title, 500)).filter(Boolean) : []; const originalTitle = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE ? normalizeText(item.title, 500) : ''; return Array.from(new Set([originalTitle, ...candidates].filter(Boolean))); } function findForbiddenTitleBrandTerm(title, forbiddenTerms = []) { const normalizedTitle = normalizeText(title, 500).replace(/\s+/g, '').toLowerCase(); if (!normalizedTitle) return ''; return Array.from(new Set((Array.isArray(forbiddenTerms) ? forbiddenTerms : []) .map((term) => normalizeText(term, 40)) .filter((term) => term.length >= 2))) .find((term) => normalizedTitle.includes(term.replace(/\s+/g, '').toLowerCase())) || ''; } function validateAdditionalProductTitleCandidates(item = {}, titles = []) { const candidates = Array.from(new Set( (Array.isArray(titles) ? titles : []) .map((title) => normalizeText(title, 500)) .filter(Boolean) )); if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) { return candidates; } const originalTitle = normalizeText(item.title, 500); const additionalCandidates = candidates.filter((title) => title !== originalTitle); if (!originalTitle || additionalCandidates.length !== 5) { const error = new Error('1688标题生成必须返回5条不同于原始标题的AI候选,请重新生成'); error.status = 502; error.code = 'INVALID_1688_TITLE_CANDIDATES'; throw error; } return additionalCandidates; } function resolvePreservedSkuNameSelection(row = {}, previousSelection = '') { const allowedNames = [row.originalSkuName, ...(Array.isArray(row.names) ? row.names : [])] .map((name) => normalizeText(name, 255)) .filter(Boolean); const normalizedSelection = normalizeText(previousSelection, 255); return allowedNames.includes(normalizedSelection) ? normalizedSelection : ''; } function alibaba1688GlobalEncodingError(message, code) { const error = new Error(message); error.status = 409; error.code = code; return error; } function getAlibaba1688SourceSkuRows(item = {}) { const product = getAlibaba1688SourceProduct(item); return Array.isArray(product.skus) ? product.skus : []; } function normalizeAlibaba1688ProductId(value) { return normalizeMarketItemLookupId(value).replace(/^1688:/i, ''); } function resolveAlibaba1688GlobalEncoding(item = {}, encoding = null) { const productId = normalizeAlibaba1688ProductId(item.marketItemId || item.selectedSpuCode); if (!encoding || encoding.status !== 'encoded') { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 尚未完成全局编码,不能继续定价或上架`, 'ALIBABA_1688_PRODUCT_NOT_ENCODED' ); } if (encoding.productId && normalizeAlibaba1688ProductId(encoding.productId) !== productId) { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 的全局编码记录与当前商品不一致`, 'ALIBABA_1688_PRODUCT_ENCODING_MISMATCH' ); } const sourceSkus = getAlibaba1688SourceSkuRows(item); const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(sourceSkus); if (!sourceSkus.length || sourceSkuFingerprint !== normalizeText(encoding.sourceSkuFingerprint, 64) || Number(encoding.sourceSkuCount) !== sourceSkus.length) { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 的SKU已在编码后发生变化,不能复用原编码`, 'ALIBABA_1688_PRODUCT_ENCODING_STALE' ); } const mappings = Array.isArray(encoding.skuMappings) ? encoding.skuMappings : []; const mappingBySourceKey = new Map(); for (const mapping of mappings) { const sourceSkuKey = normalizeText(mapping?.sourceSkuKey, 4000); const productCode = normalizeText(mapping?.productCode, 160); if (!sourceSkuKey || !productCode || mappingBySourceKey.has(sourceSkuKey)) { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 的全局SKU编码映射不完整`, 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' ); } mappingBySourceKey.set(sourceSkuKey, { sourceSkuKey, sourceSkuIndex: Number(mapping.sourceSkuIndex) || null, sourceSkuName: normalizeText(mapping.sourceSkuName, 1200), productName: normalizeText(mapping.productName, 255), colorSpec: normalizeText(mapping.colorSpec, 500), styleCode: normalizeText(mapping.styleCode || encoding.styleCode, 160), productCode, }); } const resolvedMappings = sourceSkus.map((sku, index) => { const sourceSkuKey = buildAlibaba1688SkuKey(sku, index); const mapping = mappingBySourceKey.get(sourceSkuKey); if (!mapping) { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 的第${index + 1}个SKU缺少全局编码映射`, 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' ); } return mapping; }); if (resolvedMappings.length !== mappings.length || new Set(resolvedMappings.map((mapping) => mapping.productCode)).size !== resolvedMappings.length) { throw alibaba1688GlobalEncodingError( `1688商品 ${productId || '--'} 的全局SKU编码映射数量不一致`, 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' ); } return { productId, status: 'encoded', styleCode: normalizeText(encoding.styleCode, 160), sourceSkuCount: sourceSkus.length, sourceSkuFingerprint, request: encoding.request && typeof encoding.request === 'object' ? encoding.request : null, response: encoding.response && typeof encoding.response === 'object' ? encoding.response : null, startedAt: encoding.startedAt || null, encodedAt: encoding.encodedAt || null, skuMappings: resolvedMappings, }; } function applyAlibaba1688GlobalEncodingToSnapshot(snapshot = {}, globalEncoding = {}) { const mappingBySourceKey = new Map( (Array.isArray(globalEncoding.skuMappings) ? globalEncoding.skuMappings : []) .map((mapping) => [mapping.sourceSkuKey, mapping]) ); const rows = (Array.isArray(snapshot.rows) ? snapshot.rows : []).map((row, index) => { const sourceSkuKey = normalizeText(row.sourceSkuKey, 4000) || buildAlibaba1688SkuKey(row.raw || {}, index); const mapping = mappingBySourceKey.get(sourceSkuKey); if (!mapping?.productCode) { throw alibaba1688GlobalEncodingError( `SKU ${row.skuCode || index + 1} 缺少可复用的全局商品编码`, 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' ); } const purchasePrice = resolveAlibaba1688SkuPurchasePrice(row.raw || row); return { ...row, sourceSkuKey, internalProductCode: mapping.productCode, productCodeProductName: mapping.productName, productCodeColorSpec: mapping.colorSpec, ...(purchasePrice !== null ? { cost: purchasePrice, purchasePrice } : {}), }; }); return { ...snapshot, rows }; } function buildAlibaba1688GlobalEncodingState(globalEncoding = {}) { const request = globalEncoding.request && typeof globalEncoding.request === 'object' ? globalEncoding.request : {}; const config = { operationCenter: normalizeText(request.operationCenter, 160), categoryName: normalizeText(request.categoryName, 255), ...Object.fromEntries(PRODUCT_CODE_CONFIG_FIELDS.map((field) => [ field, normalizeText(request[field], 160), ])), }; const requestHash = crypto.createHash('sha256').update(JSON.stringify({ productId: globalEncoding.productId, sourceSkuFingerprint: globalEncoding.sourceSkuFingerprint, productCodes: globalEncoding.skuMappings?.map((mapping) => mapping.productCode) || [], })).digest('hex'); return { status: 'completed', styleCode: normalizeText(globalEncoding.styleCode, 160), requestHash, config, request, response: globalEncoding.response || {}, requestedAt: globalEncoding.startedAt || globalEncoding.encodedAt || null, completedAt: globalEncoding.encodedAt || null, }; } async function loadAlibaba1688GlobalEncoding(item = {}, options = {}) { const productId = normalizeAlibaba1688ProductId(item.marketItemId || item.selectedSpuCode); const encoding = await (options.getEncoding || getEncodingByProductId)(productId); return resolveAlibaba1688GlobalEncoding(item, encoding); } function buildAlibaba1688SkuSnapshot(item = {}) { const sourceRow = item?.metadata?.sourceRow && typeof item.metadata.sourceRow === 'object' ? item.metadata.sourceRow : {}; const product = sourceRow.product && typeof sourceRow.product === 'object' ? sourceRow.product : {}; const rawRows = Array.isArray(product.skus) && product.skus.length > 0 ? product.skus : Array.isArray(product.baseSkuRows) ? product.baseSkuRows : []; const embeddedMappings = new Map( (Array.isArray(sourceRow.encoding?.skuMappings) ? sourceRow.encoding.skuMappings : []) .map((mapping) => [normalizeText(mapping?.sourceSkuKey, 4000), mapping]) .filter(([sourceSkuKey]) => sourceSkuKey) ); const fallbackImage = normalizeImageUrl( item.mainImageUrl || product.mainImage || product.mainImages?.[0] ); const rows = rawRows.map((sku, index) => { const specs = Array.isArray(sku.specs) ? sku.specs.map((entry) => ({ name: normalizeText(entry?.name || entry?.propertyName || entry?.attributeName, 80), value: normalizeText(entry?.value || entry?.propertyValue || entry?.specValue, 160), })).filter((entry) => entry.name && entry.value) : []; const skuCode = normalizeText( sku.skuCode || sku.skuId || sku.id || `${item.marketItemId}-SKU-${index + 1}`, 160 ); const price = resolveAlibaba1688SkuPurchasePrice(sku); const sourceSkuKey = buildAlibaba1688SkuKey(sku, index); const encodingMapping = embeddedMappings.get(sourceSkuKey); return { skuCode, sourceSkuKey, skuName: normalizeText( sku.name || specs.map((entry) => `${entry.name}:${entry.value}`).join(' / ') || sku.selectedText || item.title, 255 ), specs, productName: item.title, skuImageUrl: normalizeImageUrl(sku.image || sku.imageUrl || fallbackImage), cost: price, purchasePrice: price, minimumPrice: normalizeDecimal(sku.minimumPrice), freight: normalizeNullableDecimal(sku.freight), freightText: normalizeText(sku.freightText, 120), stock: normalizeNullableDecimal(sku.stock), stockText: normalizeText(sku.stockText, 120), sourceSystem: 'alibaba_1688_products', internalProductCode: normalizeText(encodingMapping?.productCode, 160), productCodeProductName: normalizeText(encodingMapping?.productName, 255), productCodeColorSpec: normalizeText(encodingMapping?.colorSpec, 500), raw: sku, }; }); return { spuCode: item.marketItemId, total: rows.length, rows, fetchedAt: new Date().toISOString(), source: 'alibaba_1688_products', warnings: [], }; } function getAlibaba1688SourceProduct(item = {}) { const metadata = item?.metadata && typeof item.metadata === 'object' ? item.metadata : {}; const sourceRow = metadata.sourceRow && typeof metadata.sourceRow === 'object' ? metadata.sourceRow : {}; if (sourceRow.product && typeof sourceRow.product === 'object') return sourceRow.product; if (item.selectedSpuPayload && typeof item.selectedSpuPayload === 'object') { return item.selectedSpuPayload; } return {}; } function getAlibaba1688SourceImages(item = {}) { const product = getAlibaba1688SourceProduct(item); const mainImages = Array.from(new Set([ item.mainImageUrl, product.mainImage, ...(Array.isArray(product.mainImages) ? product.mainImages : []), ...(Array.isArray(product.images) ? product.images : []), ].map(normalizeImageUrl).filter(Boolean))); const detailImages = Array.from(new Set( (Array.isArray(product.detailImages) ? product.detailImages : []) .map(normalizeImageUrl) .filter(Boolean) )); return { mainImages, detailImages }; } function buildAlibaba1688SourceReadiness(item = {}) { if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; const product = getAlibaba1688SourceProduct(item); const snapshot = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot : buildAlibaba1688SkuSnapshot(item); const skuRows = Array.isArray(snapshot?.rows) ? snapshot.rows : []; const { mainImages, detailImages } = getAlibaba1688SourceImages(item); const title = normalizeText(item.title || product.title, 500); const pricedSkuCount = skuRows.filter((row) => Number(row.purchasePrice ?? row.cost) > 0).length; const skuImageCount = skuRows.filter((row) => normalizeImageUrl(row.skuImageUrl)).length; const freightSkuCount = skuRows.filter((row) => ( Number(row.freight) > 0 || normalizeText(row.freightText, 120) )).length; const stockSkuCount = skuRows.filter((row) => ( (row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock))) || normalizeText(row.stockText, 120) )).length; const blockingIssues = []; const warnings = []; if (!title) blockingIssues.push({ field: 'title', message: '缺少1688原始标题' }); if (mainImages.length === 0) blockingIssues.push({ field: 'mainImages', message: '缺少1688商品主图' }); if (detailImages.length === 0) blockingIssues.push({ field: 'detailImages', message: '缺少1688详情图' }); if (skuRows.length === 0) blockingIssues.push({ field: 'skuRows', message: '缺少1688真实SKU明细' }); if (skuRows.length > 0 && pricedSkuCount === 0) { blockingIssues.push({ field: 'skuPrices', message: 'SKU明细缺少真实采购价' }); } if (skuRows.length > 0 && skuImageCount < skuRows.length) { warnings.push({ field: 'skuImages', message: `${skuRows.length - skuImageCount}个SKU没有独立图片,将仅保留原始缺失状态` }); } if (skuRows.length > 0 && freightSkuCount === 0) { warnings.push({ field: 'freight', message: '源数据没有运费证据,定价时必须人工确认运费' }); } if (skuRows.length > 0 && stockSkuCount === 0) { warnings.push({ field: 'stock', message: '源数据没有库存证据,上架前需人工确认库存' }); } return { complete: blockingIssues.length === 0, blockingIssues, warnings, facts: { title, sourceUrl: normalizeText(product.sourceUrl || item.marketDetailUrl, 2000), storeName: normalizeText(product.storeName || item.shopName, 255), collectedAt: product.collectedAt || item.createdAt || null, mainImageCount: mainImages.length, detailImageCount: detailImages.length, skuCount: skuRows.length, pricedSkuCount, skuImageCount, freightSkuCount, stockSkuCount, }, }; } function buildAlibaba1688PricingEvidence(item = {}) { if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; const product = getAlibaba1688SourceProduct(item); const rows = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : buildAlibaba1688SkuSnapshot(item).rows; return { source: 'alibaba_1688_products', priceText: normalizeText(product.priceText, 160), minPrice: normalizeDecimal(product.minPrice), maxPrice: normalizeDecimal(product.maxPrice), freightEvidenceCount: rows.filter((row) => ( Number(row.freight) > 0 || normalizeText(row.freightText, 120) )).length, stockEvidenceCount: rows.filter((row) => ( (row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock))) || normalizeText(row.stockText, 120) )).length, rows: rows.map((row) => ({ skuCode: normalizeText(row.skuCode, 160), originalSkuName: normalizeText(row.skuName, 255), purchasePrice: normalizeDecimal(row.purchasePrice ?? row.cost), freight: normalizeNullableDecimal(row.freight), freightText: normalizeText(row.freightText, 120), stock: row.stock != null && row.stock !== '' && Number.isFinite(Number(row.stock)) ? Number(row.stock) : null, stockText: normalizeText(row.stockText, 120), })), }; } function buildAlibaba1688ListingCompliance(item = {}, plan = {}) { if (getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; const sourceReadiness = buildAlibaba1688SourceReadiness(item); const blockers = [...(sourceReadiness?.blockingIssues || [])]; const warnings = [...(sourceReadiness?.warnings || [])]; const selectedSkuRows = getWorkflowSelectedSkuRows( item, Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : [] ); const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows || []); const pricingRowsBySku = new Map(); let hasDuplicatePricingSku = false; for (const row of pricingRows) { const skuCode = normalizeSkuCode(row.skuCode); if (!skuCode) continue; if (pricingRowsBySku.has(skuCode)) hasDuplicatePricingSku = true; else pricingRowsBySku.set(skuCode, row); } const encodedSkuCount = selectedSkuRows.filter((row) => { const pricingRow = pricingRowsBySku.get(normalizeSkuCode(row.skuCode)); return Boolean( normalizeText(pricingRow?.internalProductCode, 160) && normalizeText(pricingRow?.productCodeProductName, 255) && normalizeText(pricingRow?.productCodeColorSpec, 500) ); }).length; const productCodesComplete = selectedSkuRows.length > 0 && pricingRows.length === selectedSkuRows.length && pricingRowsBySku.size === selectedSkuRows.length && !hasDuplicatePricingSku && encodedSkuCount === selectedSkuRows.length; const finalTitle = normalizeText(item.selectedProductTitle, 500); if (!finalTitle) blockers.push({ field: 'selectedProductTitle', message: '尚未确认最终上架标题' }); if (/(微信|VX|V信|手机号|电话|二维码|加我|联系我)/i.test(finalTitle)) { blockers.push({ field: 'titleCompliance', message: '最终标题疑似包含联系方式或导流词' }); } const forbiddenBrandTerm = findForbiddenTitleBrandTerm( finalTitle, getTaskForbiddenBrandTerms(item) ); if (forbiddenBrandTerm) { blockers.push({ field: 'titleReferenceBrand', message: `最终标题包含参考商品品牌或型号“${forbiddenBrandTerm}”`, }); } if (selectedSkuRows.length === 0) blockers.push({ field: 'selectedSkus', message: '尚未选择本次上架SKU' }); if (!item.pricingTableSavedAt) blockers.push({ field: 'pricing', message: '定价表尚未确认保存' }); if (normalizeText(item.productCodeStatus, 32) !== 'completed' || !productCodesComplete) { blockers.push({ field: 'productCodes', message: '本次上架SKU的公司编码、编码名称或颜色规格尚未逐行完整回写' }); } if (pricingRows.length !== selectedSkuRows.length || pricingRows.some((row) => !row.selectedSkuName)) { blockers.push({ field: 'skuNames', message: '本次上架SKU名称尚未全部确认' }); } if (pricingRows.length !== selectedSkuRows.length || pricingRows.some((row) => ( Number(row.groupPrice) <= 0 ))) { blockers.push({ field: 'skuPrices', message: '本次上架SKU价格尚未全部确认' }); } const generatedMainImages = Array.isArray(item.mainImageGeneration?.rows) ? item.mainImageGeneration.rows.filter((row) => normalizeImageUrl(row.imageUrl)) : []; const requiredMainImageCount = getConfiguredMainImageCount(item, 1); if (generatedMainImages.length < requiredMainImageCount) { blockers.push({ field: 'mainImages', message: `AI主图不足${requiredMainImageCount}张` }); } if (selectedSkuRows.some((row) => !getVerifiedSkuImageRow(item, row.skuCode))) { blockers.push({ field: 'skuImages', message: '本次上架SKU仍有未处理或身份不匹配的SKU图' }); } if (!normalizeText(plan.listingShopId || plan.listingShop?.shopId, 160)) { warnings.push({ field: 'listingShop', message: '尚未选择目标店铺,可在提交自动上架时确认' }); } return { complete: blockers.length === 0, blockers, warnings, checks: { sourceComplete: Boolean(sourceReadiness?.complete), selectedSkuCount: selectedSkuRows.length, encodedSkuCount, pricedSkuCount: pricingRows.filter((row) => Number(row.groupPrice) > 0).length, generatedMainImageCount: generatedMainImages.length, requiredMainImageCount, generatedSkuImageCount: selectedSkuRows.filter((row) => ( getVerifiedSkuImageRow(item, row.skuCode) )).length, targetPlatform: normalizeText(plan.listingShopPlatform || plan.listingShop?.platform, 80), targetShopName: normalizeText(plan.listingShopName || plan.listingShop?.shopName, 255), }, }; } function getRevisionActor(user = {}) { return normalizeText( user.username || user.userName || user.email || getDingtalkUserName(user) || getActorId(user), 120 ); } async function recordSelectionFissionRevision({ planId, planItemId = null, revisionType, summary = '', snapshot = null, user = {}, }) { await ensureSelectionFissionSchema(); const normalizedPlanId = Number(planId); const normalizedItemId = Number(planItemId) || null; const normalizedType = normalizeText(revisionType, 64); if (!normalizedPlanId || !normalizedType) return null; const rows = await planningDatabase.query( `SELECT COALESCE(MAX(revision_no), 0) AS revision_no FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} WHERE plan_id = ? AND revision_type = ? AND ${normalizedItemId ? 'plan_item_id = ?' : 'plan_item_id IS NULL'}`, normalizedItemId ? [normalizedPlanId, normalizedType, normalizedItemId] : [normalizedPlanId, normalizedType] ); const revisionNo = Number(rows[0]?.revision_no || 0) + 1; const result = await planningDatabase.execute( `INSERT INTO ${escapeIdentifier(REVISION_HISTORY_TABLE)} ( plan_id, plan_item_id, revision_type, revision_no, summary, snapshot_json, created_by, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, [ normalizedPlanId, normalizedItemId, normalizedType, revisionNo, normalizeText(summary, 500) || null, snapshot == null ? null : safeJsonStringify(snapshot), getRevisionActor(user) || null, ] ); const insertId = Array.isArray(result) ? result[0]?.insertId : result?.insertId; return { id: insertId || null, revisionNo }; } async function listSelectionFissionRevisionHistory(planId, options = {}) { await ensureSelectionFissionSchema(); const normalizedPlanId = Number(planId); if (!normalizedPlanId) { const error = new Error('方案ID无效'); error.status = 400; throw error; } const page = Math.max(1, Number(options.page) || 1); const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(options.pageSize || options.page_size) || 20)); const offset = (page - 1) * pageSize; const [countRows, rows] = await Promise.all([ planningDatabase.query( `SELECT COUNT(*) AS total FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} WHERE plan_id = ?`, [normalizedPlanId] ), planningDatabase.query( `SELECT * FROM ${escapeIdentifier(REVISION_HISTORY_TABLE)} WHERE plan_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, [normalizedPlanId, pageSize, offset] ), ]); return { page, pageSize, total: Number(countRows[0]?.total || 0), rows: rows.map((row) => ({ id: row.id, planId: row.plan_id, planItemId: row.plan_item_id, revisionType: row.revision_type, revisionNo: Number(row.revision_no || 0), summary: row.summary || '', snapshot: safeJsonParse(row.snapshot_json, null), createdBy: row.created_by || '', createdAt: row.created_at, })), }; } async function buildSelectionFissionRevisionSnapshot(planId, planItemId, existingPlan = null) { const plan = existingPlan || await getSelectionFissionPlanDetail(planId); const item = plan.items.find((row) => Number(row.id) === Number(planItemId)); if (!item || getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) return null; return { workflowProfile: ALIBABA_1688_PENDING_PROFILE, sourceReadiness: item.sourceReadiness || buildAlibaba1688SourceReadiness(item), pricingEvidence: item.pricingEvidence || buildAlibaba1688PricingEvidence(item), selectedMarketReferences: item.selectedMarketReferences || [], mainImageGeneration: item.mainImageGeneration || null, skuSceneImages: item.skuSceneImages || null, skuNameCandidates: item.skuNameCandidates || null, selectedProductTitle: item.selectedProductTitle || '', pricingTableVersion: Number(item.pricingTableVersion || 0), pricingTableSavedAt: item.pricingTableSavedAt || null, pricingRows: (item.pricingRows || []).map((row) => ({ skuCode: row.skuCode, originalSkuName: row.originalSkuName, selectedSkuName: row.selectedSkuName, purchasePrice: row.purchasePrice ?? row.cost, freightNational: row.freightNational, finalPrice: row.finalPrice, groupPrice: row.groupPrice, })), listingCompliance: item.listingCompliance || buildAlibaba1688ListingCompliance(item, plan), }; } async function recordCurrentSelectionFissionRevision({ planId, planItemId, revisionType, summary, user = {}, plan = null, }) { const snapshot = await buildSelectionFissionRevisionSnapshot(planId, planItemId, plan); if (!snapshot) return null; return recordSelectionFissionRevision({ planId, planItemId, revisionType, summary, snapshot, user, }); } async function listSelectionFissionUserShops(userOrName, options = {}) { await ensureSelectionFissionSchema(); const dingtalkUserName = typeof userOrName === 'string' ? normalizeText(userOrName, 120) : getDingtalkUserName(userOrName || {}); if (!dingtalkUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const includeInactive = options.includeInactive === true; const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(USER_SHOP_TABLE)} WHERE dingtalk_user_name = ? ${includeInactive ? '' : "AND status = 'active'"} ORDER BY status = 'active' DESC, platform ASC, shop_name ASC, id ASC`, [dingtalkUserName] ); const presence = await listOnlineDesktopUsers(); const decorated = decorateListingShopsWithPresence( rows.map(mapListingShop), presence, dingtalkUserName ); return { dingtalkUserName, total: rows.length, selectableTotal: decorated.rows.filter((shop) => shop.selectable).length, desktop: decorated.desktop, rows: decorated.rows, }; } async function listSelectionFissionExecutionOptions(user = {}, options = {}) { await ensureSelectionFissionSchema(); const dingtalkUserName = getDingtalkUserName(user); if (!dingtalkUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const includeInactive = options.includeInactive === true; const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(USER_SHOP_TABLE)} WHERE status = 'active' OR (dingtalk_user_name = ? ${includeInactive ? '' : "AND status = 'active'"}) ORDER BY status = 'active' DESC, platform ASC, shop_name ASC, id ASC`, [dingtalkUserName] ); const presence = await listOnlineDesktopUsers(); const mappedRows = rows.map(mapListingShop); const ownRows = mappedRows.filter((shop) => isSameListingUser( { name: dingtalkUserName, username: user.username || user.userName || user.email }, { name: shop.dingtalkUserName, username: shop.dingtalkUserName } )); const decoratedOwn = decorateListingShopsWithPresence(ownRows, presence, dingtalkUserName); const execution = buildSelectionFissionExecutionOptions(mappedRows, presence, user); return { dingtalkUserName, total: ownRows.length, selectableTotal: decoratedOwn.rows.filter((shop) => shop.selectable).length, desktop: decoratedOwn.desktop, rows: decoratedOwn.rows, executionShops: execution.shops, executionSessions: execution.sessions, recommended: execution.recommended, availableSessionCount: execution.availableSessionCount, onlineUserCount: execution.onlineUserCount, }; } async function syncSelectionFissionUserShops(input = {}, context = {}) { await ensureSelectionFissionSchema(); const dingtalkUserName = normalizeText( input.dingtalkUserName || input.dingtalk_user_name || input.dingtalkUsername || input.userName || input.user_name, 120 ); if (!dingtalkUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const inputShops = Array.isArray(input.shops) ? input.shops : []; if (inputShops.length > MAX_SHOP_SYNC_ROWS) { const error = new Error(`单次最多同步 ${MAX_SHOP_SYNC_ROWS} 个店铺`); error.status = 400; throw error; } const normalizedShops = inputShops.map(normalizeListingShop); const shopsById = new Map(); for (const shop of normalizedShops) { shopsById.set(shop.shopId, shop); } const shops = Array.from(shopsById.values()); const replaceExisting = input.replaceExisting !== false && input.replace_existing !== false; const syncClientId = normalizeText(input.clientId || input.client_id, 64).toLowerCase(); await planningDatabase.withTransaction(async (connection) => { if (replaceExisting) { await connection.execute( `UPDATE ${escapeIdentifier(USER_SHOP_TABLE)} SET status = 'inactive', updated_at = NOW() WHERE dingtalk_user_name = ? ${syncClientId ? 'AND (client_id = ? OR client_id IS NULL)' : ''}`, syncClientId ? [dingtalkUserName, syncClientId] : [dingtalkUserName] ); } for (const shop of shops) { await connection.execute( `INSERT INTO ${escapeIdentifier(USER_SHOP_TABLE)} ( dingtalk_user_name, shop_id, shop_code, shop_name, platform, account_name, status, client_id, login_status, login_message, last_login_checked_at, last_seen_at, api_key_id, api_key_name, last_synced_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, ?, NOW(), NOW(), NOW()) ON DUPLICATE KEY UPDATE shop_code = VALUES(shop_code), shop_name = VALUES(shop_name), platform = VALUES(platform), account_name = VALUES(account_name), status = VALUES(status), client_id = VALUES(client_id), login_status = VALUES(login_status), login_message = VALUES(login_message), last_login_checked_at = VALUES(last_login_checked_at), last_seen_at = NOW(), api_key_id = VALUES(api_key_id), api_key_name = VALUES(api_key_name), last_synced_at = NOW(), updated_at = NOW()`, [ dingtalkUserName, shop.shopId, shop.shopCode || null, shop.shopName, shop.platform || null, shop.accountName || null, shop.status, shop.clientId || syncClientId || null, shop.loginStatus, shop.loginMessage || null, shop.lastLoginCheckedAt, context.apiKeyId ? String(context.apiKeyId) : null, normalizeText(context.apiKeyName, 120) || null, ] ); } }); const result = await listSelectionFissionUserShops(dingtalkUserName, { includeInactive: true }); return { ...result, synced: shops.length, activeTotal: result.rows.filter((shop) => shop.enabled).length, replaceExisting, }; } function normalizeSelectionFissionPlanItems(inputItems = []) { if (inputItems.length < 1 || inputItems.length > 5) { const error = new Error('裂变方案每次可选择 1~5 个市场ID'); error.status = 400; throw error; } const items = inputItems.map(normalizePlanInputItem); const uniqueIds = new Set(items.map((item) => `${item.channelName || 'unknown'}:${item.marketItemId}`)); if (uniqueIds.size !== items.length) { const error = new Error('同一套裂变方案中不能重复添加相同市场ID'); error.status = 400; throw error; } return items; } async function createSelectionFissionPlan(input = {}, user = {}) { await ensureSelectionFissionSchema(); const items = await applyCanonicalMarketPriceBands( normalizeSelectionFissionPlanItems(Array.isArray(input.items) ? input.items : []) ); const workflowProfile = normalizeText(input.workflowProfile, 80); if (workflowProfile === ALIBABA_1688_PENDING_PROFILE) { const sourceChecks = items.map((item) => { item.metadata = item.metadata && typeof item.metadata === 'object' ? item.metadata : {}; item.metadata.workflowProfile = ALIBABA_1688_PENDING_PROFILE; return { marketItemId: item.marketItemId, readiness: buildAlibaba1688SourceReadiness(item), }; }); const blocked = sourceChecks.filter((entry) => !entry.readiness?.complete); if (blocked.length > 0) { const error = new Error('所选1688商品源数据不完整,不能进入待上架流程'); error.status = 409; error.code = 'ALIBABA_1688_SOURCE_INCOMPLETE'; error.data = { sourceChecks }; throw error; } } const actorId = normalizeText(getActorId(user), 120); const username = normalizeText(user.username || user.userName || user.email || '', 120); const dingtalkUserName = normalizeText(input.dingtalkUserName || getDingtalkUserName(user), 120); const departmentName = normalizeText(input.departmentName || getDepartmentName(user), 120); const schemeCode = normalizeText(input.schemeCode, 64) || buildSchemeCode(); const listingShopId = normalizeText( input.listingShopId || input.listing_shop_id || input.shopId || input.shop_id, 160 ); let listingShop = null; if (listingShopId) { const shopRows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(USER_SHOP_TABLE)} WHERE dingtalk_user_name = ? AND shop_id = ? AND status = 'active' LIMIT 1`, [dingtalkUserName, listingShopId] ); if (!shopRows[0]) { const error = new Error('所选店铺不属于当前钉钉用户、已停用或尚未由第三方应用同步'); error.status = 400; throw error; } listingShop = mapListingShop(shopRows[0]); } const created = await planningDatabase.withTransaction(async (connection) => { const [planResult] = await connection.execute( `INSERT INTO ${escapeIdentifier(PLAN_TABLE)} ( scheme_code, dingtalk_user_id, dingtalk_username, dingtalk_user_name, department_name, workflow_status, workflow_step, workflow_progress, status, item_count, listed_item_count, listing_shop_id, listing_shop_code, listing_shop_name, listing_shop_platform, listing_shop_snapshot_json, created_by_user_id, created_by_username, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'draft', 0, 0, 'pending', ?, 0, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, [ schemeCode, actorId || null, username || null, dingtalkUserName || null, departmentName || null, items.length, listingShop?.shopId || null, listingShop?.shopCode || null, listingShop?.shopName || null, listingShop?.platform || null, listingShop ? safeJsonStringify(listingShop) : null, actorId || null, username || dingtalkUserName || null, ] ); const planId = planResult.insertId; for (const item of items) { if (workflowProfile && !item.metadata.workflowProfile) { item.metadata.workflowProfile = workflowProfile; } const [itemResult] = await connection.execute( `INSERT INTO ${escapeIdentifier(ITEM_TABLE)} ( plan_id, slot_no, market_item_id, channel_name, title, shop_name, category_level1, category_level2, category_level3, main_image_url, market_detail_url, price_band, price_band_list_json, metadata_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, [ planId, item.slotNo, item.marketItemId, item.channelName || null, item.title || null, item.shopName || null, item.categoryLevel1 || null, item.categoryLevel2 || null, item.categoryLevel3 || null, item.mainImageUrl || null, item.marketDetailUrl || null, item.priceBand || null, safeJsonStringify(item.priceBandList || []), safeJsonStringify(item.metadata), ] ); const itemId = itemResult.insertId; const itemWorkflowProfile = getSelectionWorkflowProfile(item); if (itemWorkflowProfile === ALIBABA_1688_PENDING_PROFILE) { const skuSnapshot = buildAlibaba1688SkuSnapshot(item); if (skuSnapshot.rows.length === 0) { const error = new Error(`1688商品 ${item.marketItemId} 缺少真实SKU明细,请先重新采集完整商品详情`); error.status = 400; throw error; } const whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); const mainSku = skuSnapshot.rows.find((row) => row.skuImageUrl) || skuSnapshot.rows[0]; const { candidate: preferredMarketReference, batch: preferredReferenceBatch, } = buildPreferredMarketReferenceSeed(item.metadata?.sourceRow?.preferredMarketReference); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET source_item_id = ?, task_code = ?, listing_status = 'draft', selected_spu_code = ?, selected_spu_name = ?, selected_spu_payload_json = ?, sku_snapshot_json = ?, white_background_assets_json = ?, selected_main_sku_code = ?, selected_main_sku_name = ?, selected_main_sku_image_url = ?, market_reference_batch_json = ?, selected_market_references_json = ?, workflow_status = 'waiting', workflow_step = 8, updated_at = NOW() WHERE id = ?`, [ itemId, `${schemeCode}-S${String(item.slotNo).padStart(2, '0')}-01`, item.marketItemId, item.title || item.marketItemId, safeJsonStringify(item.metadata.sourceRow?.product || item.metadata), safeJsonStringify(skuSnapshot), safeJsonStringify(whiteBackgroundAssets), mainSku?.skuCode || null, mainSku?.skuName || null, mainSku?.skuImageUrl || item.mainImageUrl || null, preferredReferenceBatch ? safeJsonStringify(preferredReferenceBatch) : null, preferredMarketReference ? safeJsonStringify([preferredMarketReference]) : null, itemId, ] ); } else { await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET source_item_id = ?, task_code = ?, listing_status = 'draft', workflow_status = 'draft', workflow_step = 0, updated_at = NOW() WHERE id = ?`, [itemId, `${schemeCode}-S${String(item.slotNo).padStart(2, '0')}-01`, itemId] ); } } return { id: planId, schemeCode }; }); return { ...(await getSelectionFissionPlanDetail(created.id)), warnings: [], }; } async function replaceSelectionFissionSupplySource(planId, planItemId, input = {}, user = {}) { await ensureSelectionFissionSchema(); const current = await getPlanItem(planId, planItemId); if (current.listingStatus !== 'draft' || current.listedItemId) { const error = new Error('该商品已经提交上架,不能再切换供货来源'); error.status = 409; throw error; } if (current.workflowStatus === 'running') { const error = new Error('当前节点正在处理,请完成后再切换供货来源'); error.status = 409; throw error; } const nextItem = normalizePlanInputItem(input.item || input, 0); nextItem.metadata = nextItem.metadata && typeof nextItem.metadata === 'object' ? nextItem.metadata : {}; nextItem.metadata.workflowProfile = ALIBABA_1688_PENDING_PROFILE; const readiness = buildAlibaba1688SourceReadiness(nextItem); if (!readiness?.complete) { const error = new Error('所选1688商品源数据不完整,不能替换当前供货方案'); error.status = 409; error.code = 'ALIBABA_1688_SOURCE_INCOMPLETE'; error.data = { readiness }; throw error; } await loadAlibaba1688GlobalEncoding(nextItem); const originalMetadata = current.metadata && typeof current.metadata === 'object' ? current.metadata : {}; const marketEvidence = current.marketEvidence || originalMetadata.marketEvidence || null; const metadata = { ...nextItem.metadata, workflowProfile: ALIBABA_1688_PENDING_PROFILE, marketEvidence, originMarketSelection: originalMetadata.originMarketSelection || { marketItemId: current.marketItemId, channelName: current.channelName, title: current.title, shopName: current.shopName, categoryLevel1: current.categoryLevel1, categoryLevel2: current.categoryLevel2, categoryLevel3: current.categoryLevel3, mainImageUrl: current.mainImageUrl, marketDetailUrl: current.marketDetailUrl, priceBand: current.priceBand, priceBandList: current.priceBandList, }, originCompanySelection: { selectedSpuCode: current.selectedSpuCode, selectedSpuName: current.selectedSpuName, selectedMainSkuCode: current.selectedMainSkuCode, }, supplyComparison: { company: current.supplyDecision || originalMetadata.supplyDecision || null, finalSource: '1688', reason: normalizeText(input.reason, 500) || '用户选择继续寻找更优货源', switchedAt: new Date().toISOString(), switchedBy: getDingtalkUserName(user), }, supplyDecision: null, }; const preparedItem = { ...nextItem, metadata }; const skuSnapshot = buildAlibaba1688SkuSnapshot(preparedItem); const whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); const mainSku = skuSnapshot.rows.find((row) => row.skuImageUrl) || skuSnapshot.rows[0]; const originMarket = metadata.originMarketSelection || {}; const preferredReference = nextItem.metadata?.sourceRow?.preferredMarketReference || ( originMarket.marketItemId && originMarket.mainImageUrl ? { marketItemId: originMarket.marketItemId, channelName: originMarket.channelName, title: originMarket.title, imageUrl: originMarket.mainImageUrl, detailUrl: originMarket.marketDetailUrl, } : null ); const { candidate: seededReference, batch: seededReferenceBatch } = buildPreferredMarketReferenceSeed( preferredReference ); await planningDatabase.withTransaction(async (connection) => { for (const tableName of [PRICING_TABLE, ACTIVITY_PRICING_TABLE, LISTING_RESULT_TABLE, CALLBACK_HISTORY_TABLE]) { await connection.execute( `DELETE FROM ${escapeIdentifier(tableName)} WHERE plan_id = ? AND plan_item_id = ?`, [current.planId, current.id] ); } await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET market_item_id = ?, channel_name = '1688', title = ?, shop_name = ?, category_level1 = ?, category_level2 = ?, category_level3 = ?, main_image_url = ?, market_detail_url = ?, price_band = ?, price_band_list_json = ?, selected_spu_code = ?, selected_spu_name = ?, selected_spu_payload_json = ?, sku_snapshot_json = ?, white_background_assets_json = ?, selected_main_sku_code = ?, selected_main_sku_name = ?, selected_main_sku_image_url = ?, main_image_generation_json = NULL, sku_scene_images_json = NULL, sku_name_candidates_json = NULL, title_keyword_strategy_json = NULL, optimized_title_candidates_json = NULL, selected_product_title = NULL, market_reference_batch_json = ?, selected_market_references_json = ?, pricing_table_saved_at = NULL, pricing_table_saved_by = NULL, pricing_table_version = 0, product_code_status = 'not_requested', product_code_style_code = NULL, product_code_request_hash = NULL, product_code_config_json = NULL, product_code_request_json = NULL, product_code_response_json = NULL, product_code_error_message = NULL, product_code_requested_at = NULL, product_code_completed_at = NULL, metadata_json = ?, workflow_status = 'waiting', workflow_step = 8, workflow_error_message = NULL, listing_status = 'draft', listing_error_code = NULL, listing_error_message = NULL, updated_at = NOW() WHERE id = ? AND plan_id = ?`, [ nextItem.marketItemId, nextItem.title || nextItem.marketItemId, nextItem.shopName || null, nextItem.categoryLevel1 || null, nextItem.categoryLevel2 || null, nextItem.categoryLevel3 || null, nextItem.mainImageUrl || null, nextItem.marketDetailUrl || null, nextItem.priceBand || null, safeJsonStringify(nextItem.priceBandList || []), nextItem.marketItemId, nextItem.title || nextItem.marketItemId, safeJsonStringify(nextItem.metadata.sourceRow?.product || nextItem.metadata), safeJsonStringify(skuSnapshot), safeJsonStringify(whiteBackgroundAssets), mainSku?.skuCode || null, mainSku?.skuName || null, mainSku?.skuImageUrl || nextItem.mainImageUrl || null, seededReferenceBatch ? safeJsonStringify(seededReferenceBatch) : null, seededReference ? safeJsonStringify([seededReference]) : null, safeJsonStringify(metadata), current.id, current.planId, ] ); }); const plan = await getSelectionFissionPlanDetail(current.planId); await syncSelectionFissionPlanWorkflowState(current.planId, plan); await recordCurrentSelectionFissionRevision({ planId: current.planId, planItemId: current.id, revisionType: 'supply-source', summary: `公司商品成本判断后切换至1688商品 ${nextItem.marketItemId}`, user, plan, }); return getSelectionFissionPlanDetail(current.planId); } function mapSelectionFissionPlanListItem(row = {}) { return { id: Number(row.item_id || 0), planId: Number(row.item_plan_id || 0), sourceItemId: Number(row.item_source_item_id || row.item_id || 0), slotNo: Number(row.item_slot_no || 0), variantNo: Number(row.item_variant_no || 1), marketItemId: normalizeText(row.item_market_item_id, 160), channelName: normalizeText(row.item_channel_name, 80), title: normalizeText(row.item_title, 500), mainImageUrl: normalizeImageUrl(row.item_main_image_url), selectedSpuCode: normalizeText(row.item_selected_spu_code, 160), workflowProfile: normalizeText(row.item_workflow_profile, 80), workflowStep: normalizeWorkflowStep(row.item_workflow_step || 0), workflowStatus: normalizeText(row.item_workflow_status || 'draft', 20), workflowErrorMessage: normalizeText( row.item_workflow_error_message, 1000 ), listingStatus: normalizeText(row.item_listing_status || 'draft', 20), listedItemId: normalizeText(row.item_listed_item_id, 160), listingErrorCode: normalizeText(row.item_listing_error_code, 120), listingErrorMessage: normalizeText(row.item_listing_error_message, 1000), pddDraftCommitId: normalizeText(row.item_pdd_draft_commit_id, 160), }; } function buildStoredWorkflowSummary(plan = {}, sourceItems = [], listingTasks = []) { const allItems = [...sourceItems, ...listingTasks]; const failedItem = allItems.find((item) => item.workflowStatus === 'failed'); let status = normalizeText(plan.workflowStatus || 'draft', 20) || 'draft'; let currentStep = normalizeWorkflowStep(plan.workflowStep || 0); const progress = Math.min(Math.max(Number(plan.workflowProgress || 0), 0), 100); if (failedItem) { status = 'failed'; currentStep = Math.max(1, Number(failedItem.workflowStep) || 1); } const currentDefinition = getWorkflowStepDefinition(currentStep); const currentStepRank = getWorkflowStepRank(currentStep); const steps = WORKFLOW_STEP_DEFINITIONS.map((definition) => { const isCompleted = getWorkflowStepRank(definition.step) < currentStepRank || status === 'completed'; const isCurrent = currentStep === definition.step; const span = Math.max(definition.endProgress - definition.startProgress, 1); const stepProgress = isCompleted ? 100 : isCurrent ? Math.round(Math.min(Math.max( ((progress - definition.startProgress) / span) * 100, 0 ), 100)) : 0; return { ...definition, progress: stepProgress, status: isCompleted ? 'completed' : isCurrent ? status : 'pending', }; }); return { status, currentStep, currentStepKey: currentDefinition?.key || 'draft', currentStepLabel: currentDefinition?.label || '待开始裂变', progress, errorMessage: failedItem?.workflowErrorMessage || plan.workflowErrorMessage || '', sourceTotal: sourceItems.length, selectedSourceTotal: new Set(listingTasks.map((item) => item.sourceItemId)).size, taskTotal: listingTasks.length, completedTaskTotal: listingTasks.filter((item) => item.workflowStatus === 'completed').length, requiresUser: Boolean(currentDefinition?.requiresUser && status === 'waiting'), steps, }; } async function listSelectionFissionPlans(query = {}) { await ensureSelectionFissionReadSchema(); const page = normalizePositiveInteger(query.page, 1); const pageSize = normalizePositiveInteger(query.pageSize, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); const offset = (page - 1) * pageSize; const keyword = normalizeText(query.keyword, 120); const status = ['pending', 'claimed', 'failed', 'listed'].includes(query.status) ? query.status : ''; const filters = []; const params = []; if (status) { if (status === 'listed') { filters.push(`EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} si WHERE si.plan_id = p.id AND NULLIF(TRIM(si.selected_spu_code), '') IS NOT NULL ) AND NOT EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} ui WHERE ui.plan_id = p.id AND NULLIF(TRIM(ui.selected_spu_code), '') IS NOT NULL AND NULLIF(TRIM(ui.listed_item_id), '') IS NULL )`); } else if (status === 'pending') { filters.push(`EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} pi WHERE pi.plan_id = p.id AND pi.listing_status IN ('draft', 'pending', 'claimed') AND NULLIF(TRIM(pi.listed_item_id), '') IS NULL )`); } else if (status === 'claimed') { filters.push(`EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} ci WHERE ci.plan_id = p.id AND ci.listing_status = 'claimed' AND NULLIF(TRIM(ci.listed_item_id), '') IS NULL )`); } else { filters.push(`EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} fi WHERE fi.plan_id = p.id AND fi.listing_status = 'failed' AND NULLIF(TRIM(fi.listed_item_id), '') IS NULL )`); } } if (keyword) { filters.push(`( p.scheme_code LIKE ? OR p.dingtalk_user_name LIKE ? OR p.dingtalk_username LIKE ? OR p.listing_shop_name LIKE ? OR p.auto_listed_product_id LIKE ? OR p.auto_listing_error_message LIKE ? OR EXISTS ( SELECT 1 FROM ${escapeIdentifier(ITEM_TABLE)} i WHERE i.plan_id = p.id AND ( i.market_item_id LIKE ? OR i.title LIKE ? OR i.selected_spu_code LIKE ? OR i.listed_item_id LIKE ? ) ) )`); params.push(...Array(10).fill(`%${keyword}%`)); } const whereSql = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const planListColumns = ` p.id, p.scheme_code, p.dingtalk_user_id, p.dingtalk_username, p.dingtalk_user_name, p.department_name, p.workflow_status, p.workflow_step, p.workflow_progress, p.workflow_error_message, p.workflow_started_at, p.workflow_completed_at, p.status, p.listing_shop_id, p.listing_shop_code, p.listing_shop_name, p.listing_shop_platform, p.claimed_by, p.claimed_at, p.claim_expires_at, p.claim_count, p.auto_listed_product_id, p.auto_listing_completed_at, p.auto_listing_failed_at, p.auto_listing_error_code, p.auto_listing_error_message, p.item_count, p.listed_item_count, p.created_by_user_id, p.created_by_username, p.created_at, p.updated_at`; const [joinedRows, summaryRows] = await Promise.all([ planningDatabase.query( `SELECT paged.*, i.id AS item_id, i.plan_id AS item_plan_id, i.source_item_id AS item_source_item_id, i.slot_no AS item_slot_no, i.variant_no AS item_variant_no, i.market_item_id AS item_market_item_id, i.channel_name AS item_channel_name, i.title AS item_title, i.main_image_url AS item_main_image_url, i.selected_spu_code AS item_selected_spu_code, i.workflow_step AS item_workflow_step, i.workflow_status AS item_workflow_status, i.workflow_error_message AS item_workflow_error_message, i.listing_status AS item_listing_status, i.listed_item_id AS item_listed_item_id, i.listing_error_code AS item_listing_error_code, i.listing_error_message AS item_listing_error_message, i.pdd_draft_commit_id AS item_pdd_draft_commit_id, CASE WHEN LOWER(COALESCE(i.channel_name, '')) = '1688' THEN 'alibaba_1688_pending' ELSE '' END AS item_workflow_profile FROM ( SELECT ${planListColumns}, COUNT(*) OVER() AS filtered_total FROM ${escapeIdentifier(PLAN_TABLE)} p ${whereSql} ORDER BY p.id DESC LIMIT ? OFFSET ? ) paged LEFT JOIN ${escapeIdentifier(ITEM_TABLE)} i ON i.plan_id = paged.id ORDER BY paged.id DESC, i.slot_no ASC, i.variant_no ASC, i.id ASC`, [...params, pageSize, offset], { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } ), planningDatabase.query( `SELECT COUNT(*) AS total, SUM(CASE WHEN status = 'listed' THEN 1 ELSE 0 END) AS listed_total, SUM(CASE WHEN status IN ('pending', 'claimed') AND workflow_status = 'completed' THEN 1 ELSE 0 END) AS pending_total, SUM(CASE WHEN status = 'claimed' THEN 1 ELSE 0 END) AS claimed_total, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_total, SUM(CASE WHEN workflow_status = 'draft' THEN 1 ELSE 0 END) AS draft_total, SUM(CASE WHEN workflow_status IN ('running', 'waiting') THEN 1 ELSE 0 END) AS workflow_running_total, SUM(CASE WHEN workflow_status = 'completed' THEN 1 ELSE 0 END) AS workflow_completed_total, SUM(CASE WHEN workflow_status = 'failed' THEN 1 ELSE 0 END) AS workflow_failed_total FROM ${escapeIdentifier(PLAN_TABLE)}`, [], { requestTimeout: SELECTION_FISSION_LIST_QUERY_TIMEOUT_MS } ), ]); const plansById = new Map(); const itemsByPlanId = new Map(); let filteredTotal = 0; for (const joinedRow of joinedRows) { const planId = Number(joinedRow.id || 0); if (!plansById.has(planId)) plansById.set(planId, mapPlan(joinedRow)); filteredTotal = Number(joinedRow.filtered_total || filteredTotal); if (joinedRow.item_id == null) continue; const item = mapSelectionFissionPlanListItem(joinedRow); const bucket = itemsByPlanId.get(item.planId) || []; bucket.push(item); itemsByPlanId.set(item.planId, bucket); } const plans = Array.from(plansById.values()); const listPlans = plans.map((plan) => { const items = itemsByPlanId.get(plan.id) || []; const sourceById = new Map(); items.forEach((item) => { const existing = sourceById.get(item.sourceItemId); if (!existing || item.variantNo < existing.variantNo) sourceById.set(item.sourceItemId, item); }); const sourceItems = Array.from(sourceById.values()).sort((left, right) => ( left.slotNo - right.slotNo || left.id - right.id )); const listingTasks = items.filter((item) => item.selectedSpuCode); const listingStatuses = listingTasks.map((item) => item.listedItemId ? 'listed' : item.listingStatus); let status = 'draft'; if (listingStatuses.length > 0 && listingStatuses.every((value) => value === 'listed')) status = 'listed'; else if (listingStatuses.includes('claimed')) status = 'claimed'; else if (listingStatuses.includes('failed')) status = 'failed'; else if (listingStatuses.includes('pending')) status = 'pending'; const statusMeta = getAutoListingStatusMeta(status); const workflow = buildStoredWorkflowSummary(plan, sourceItems, listingTasks); const workflowProfile = items.find((item) => item.workflowProfile)?.workflowProfile || ''; const autoListedProductIds = Array.from(new Set( listingTasks.map((item) => item.listedItemId).filter(Boolean) )); return buildSelectionFissionPlanListSummary({ ...plan, workflowProfile, workflow, workflowStatus: workflow.status, workflowStep: workflow.currentStep, workflowProgress: workflow.progress, sourceItems, listingTasks, status, autoListingStatus: statusMeta.label, autoListingStatusCode: statusMeta.code, autoListedProductIds, itemCount: sourceItems.length, listingTaskCount: listingTasks.length, listedItemCount: autoListedProductIds.length, readiness: null, }); }); const summary = summaryRows[0] || {}; return { summary: { total: Number(summary.total || 0), listedTotal: Number(summary.listed_total || 0), pendingTotal: Number(summary.pending_total || 0), claimedTotal: Number(summary.claimed_total || 0), failedTotal: Number(summary.failed_total || 0), draftTotal: Number(summary.draft_total || 0), workflowRunningTotal: Number(summary.workflow_running_total || 0), workflowCompletedTotal: Number(summary.workflow_completed_total || 0), workflowFailedTotal: Number(summary.workflow_failed_total || 0), }, table: { total: filteredTotal, page, pageSize, rows: listPlans, }, }; } async function getSelectionFissionPlanDetail(planId) { await ensureSelectionFissionSchema(); const normalizedPlanId = normalizePositiveInteger(planId, 0); if (!normalizedPlanId) { const error = new Error('缺少裂变方案ID'); error.status = 400; throw error; } await resumeInterruptedSkuImageJobs(normalizedPlanId); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PLAN_TABLE)} WHERE id = ? LIMIT 1`, [normalizedPlanId] ); if (!rows[0]) { const error = new Error('裂变方案不存在'); error.status = 404; throw error; } const [plan] = await attachPlanChildren([mapPlan(rows[0])]); return plan; } function resetInterruptedSkuSceneImageState(scene = {}) { const rows = (Array.isArray(scene.rows) ? scene.rows : []).map((row) => { if (row.generatedImageUrl || row.status !== 'processing') return { ...row }; return { ...row, status: 'waiting', progress: 0, error: '', startedAt: '', completedAt: '', }; }); return { ...scene, ...summarizeSkuSceneImageRows(rows), ...summarizeSkuSceneImageRequests(rows), retrySkuCodes: [], currentBatch: 0, rows, updatedAt: new Date().toISOString(), }; } async function resumeInterruptedSkuImageJobs(planId) { if (activeWorkflowJobs.has(planId)) return; const rows = await planningDatabase.query( `SELECT id, sku_scene_images_json FROM ${escapeIdentifier(ITEM_TABLE)} WHERE plan_id = ? AND workflow_status = 'running' AND workflow_step = 4`, [planId] ); for (const row of rows) { const jobKey = `${planId}:${row.id}`; if (activeWorkflowJobs.has(planId) || activeItemWorkflowJobs.has(jobKey)) continue; const scene = safeJsonParse(row.sku_scene_images_json, null); const hasInterruptedRows = Array.isArray(scene?.rows) && scene.rows.some((item) => item.status === 'processing' && !item.generatedImageUrl); if (!hasInterruptedRows) continue; const recoveredScene = resetInterruptedSkuSceneImageState(scene); const result = await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = ?, workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ? AND plan_id = ? AND workflow_status = 'running' AND workflow_step = 4`, [safeJsonStringify(recoveredScene), row.id, planId] ); if (!Number(result?.affectedRows || 0)) continue; const started = enqueueSelectionFissionItemNode(planId, row.id, 4, { name: 'system-recovery', username: 'system-recovery', }); if (started) { console.warn(`[SelectionFissionWorkflow] resumed interrupted SKU image job ${jobKey}`); } } } async function deleteSelectionFissionTaskArtifacts(connection, planId, itemIds = []) { const normalizedIds = Array.from(new Set(itemIds.map(Number).filter((id) => id > 0))); if (normalizedIds.length === 0) return; const placeholders = normalizedIds.map(() => '?').join(', '); const params = [planId, ...normalizedIds]; for (const tableName of [ PRICING_TABLE, ACTIVITY_PRICING_TABLE, LISTING_RESULT_TABLE, CALLBACK_HISTORY_TABLE, REVISION_HISTORY_TABLE, ]) { await connection.execute( `DELETE FROM ${escapeIdentifier(tableName)} WHERE plan_id = ? AND plan_item_id IN (${placeholders})`, params ); } } async function deleteSelectionFissionSourceItem(planId, planItemId) { const plan = await getSelectionFissionPlanDetail(planId); const requestedItemId = normalizePositiveInteger(planItemId, 0); const requestedItem = plan.items.find((item) => Number(item.id) === requestedItemId); if (!requestedItem) { const error = new Error('要删除的市场ID不存在'); error.status = 404; throw error; } const sourceItemId = Number(requestedItem.sourceItemId || requestedItem.id); const sourceItems = plan.items.filter((item) => Number(item.sourceItemId || item.id) === sourceItemId); const hasSubmittedTask = sourceItems.some((item) => ( item.listingStatus !== 'draft' || item.listedItemId )); if (hasSubmittedTask) { const error = new Error('该市场ID已有自动上架任务或商品ID,不能删除'); error.status = 409; throw error; } if (activeWorkflowJobs.has(plan.id) || sourceItems.some( (item) => activeItemWorkflowJobs.has(`${plan.id}:${item.id}`) || item.workflowStatus === 'running' )) { const error = new Error('该市场ID节点正在处理,请等待完成后再删除'); error.status = 409; throw error; } const itemIds = sourceItems.map((item) => item.id); const deletingWholePlan = plan.sourceItems.length <= 1; await planningDatabase.withTransaction(async (connection) => { if (deletingWholePlan) { await connection.execute( `DELETE FROM ${escapeIdentifier(PLAN_TABLE)} WHERE id = ?`, [plan.id] ); return; } const placeholders = itemIds.map(() => '?').join(', '); await deleteSelectionFissionTaskArtifacts(connection, plan.id, itemIds); await connection.execute( `DELETE FROM ${escapeIdentifier(ITEM_TABLE)} WHERE plan_id = ? AND id IN (${placeholders})`, [plan.id, ...itemIds] ); await connection.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET item_count = ?, listed_item_count = 0, status = 'draft', updated_at = NOW() WHERE id = ?`, [plan.sourceItems.length - 1, plan.id] ); }); if (deletingWholePlan) { return { deletedPlan: true, planId: plan.id, sourceItemId }; } await syncSelectionFissionPlanWorkflowState(plan.id); return { deletedPlan: false, planId: plan.id, sourceItemId, plan: await getSelectionFissionPlanDetail(plan.id), }; } async function getPlanItem(planId, planItemId) { await ensureSelectionFissionSchema(); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ITEM_TABLE)} WHERE id = ? AND plan_id = ? LIMIT 1`, [normalizePositiveInteger(planItemId, 0), normalizePositiveInteger(planId, 0)] ); if (!rows[0]) { const error = new Error('方案市场ID明细不存在'); error.status = 404; throw error; } const [item] = await applyCanonicalMarketPriceBands([mapPlanItem(rows[0])]); const [pricingRows, activityPricingRows] = await Promise.all([ planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ? ORDER BY sku_code ASC, id ASC`, [item.id] ), getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE ? Promise.resolve([]) : planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} WHERE plan_item_id = ? ORDER BY sku_code ASC, id ASC`, [item.id] ), ]); item.pricingRows = pricingRows.map(mapPricingRow); item.activityPricingRows = activityPricingRows.map(mapActivityPricingRow); return item; } async function getPlanItemWithoutPricing(planId, planItemId) { await ensureSelectionFissionSchema(); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(ITEM_TABLE)} WHERE id = ? AND plan_id = ? LIMIT 1`, [normalizePositiveInteger(planItemId, 0), normalizePositiveInteger(planId, 0)] ); if (!rows[0]) { const error = new Error('方案市场ID明细不存在'); error.status = 404; throw error; } return mapPlanItem(rows[0]); } function getSimilarProductsConfig() { const configuredUrl = stripTrailingSlash(getFirstEnvValue([ 'SELECTION_SIMILAR_PRODUCTS_ENDPOINT_URL', 'SELECTION_SIMILAR_PRODUCTS_BASE_URL', 'OPEN_API_SIMILAR_PRODUCTS_BASE_URL', 'IMAGE_SEARCH_SIMILAR_PRODUCTS_BASE_URL', ], 'http://192.168.40.206:7503')); const token = getFirstEnvValue([ 'SELECTION_SIMILAR_PRODUCTS_BEARER_TOKEN', 'SELECTION_SIMILAR_PRODUCTS_TOKEN', 'OPEN_API_SIMILAR_PRODUCTS_TOKEN', 'IMAGE_SEARCH_SIMILAR_PRODUCTS_TOKEN', ]); const endpointUrl = /\/open-api\/(?:image-search\/similar-products|similar-products\/query)$/i.test(configuredUrl) ? configuredUrl : `${configuredUrl}/open-api/image-search/similar-products`; return { baseUrl: configuredUrl, endpointUrl, token, timeoutMs: normalizePositiveInteger(process.env.SELECTION_SIMILAR_PRODUCTS_TIMEOUT_MS, DEFAULT_EXTERNAL_TIMEOUT_MS), }; } async function resolveSimilarProductsConfig() { return getSimilarProductsRuntimeConfig(); } function getSimilarProductsRequestHeaders(token) { return { Authorization: `Bearer ${token}`, 'X-API-Key': token, 'Content-Type': 'application/json', }; } function createSimilarProductsRequestError(error, fallback) { const status = Number(error?.response?.status || error?.status || error?.statusCode || 502); const message = getExternalRequestErrorMessage(error, fallback); const requestError = createExternalServiceError( `以图搜图服务请求失败${status ? `(HTTP ${status})` : ''}:${message}`, status, 'SIMILAR_PRODUCTS_REQUEST_FAILED' ); requestError.upstreamStatus = status; requestError.upstreamCode = normalizeText(error?.code, 80); requestError.retryable = isRetryableSimilarProductsError(error); return requestError; } function isRetryableSimilarProductsError(error) { const status = Number( error?.response?.status || error?.upstreamStatus || error?.status || error?.statusCode || 0 ); const code = normalizeText(error?.code || error?.upstreamCode, 80).toUpperCase(); const message = getExternalRequestErrorMessage(error, '').toLowerCase(); return error?.retryable === true || SIMILAR_PRODUCTS_RETRYABLE_STATUSES.has(status) || SIMILAR_PRODUCTS_RETRYABLE_CODES.has(code) || /(?:econnrefused|connection refused|socket hang up|network error|fetch failed|timed?\s*out)/i.test(message); } function isSimilarProductsFallbackEligible(error) { if (error?.code === 'EXTERNAL_SERVICE_NOT_CONFIGURED') return true; if (error?.code === 'SIMILAR_PRODUCTS_REQUEST_FAILED') { return isRetryableSimilarProductsError(error); } const status = Number(error?.status || error?.statusCode || 0); return status >= 500 || isRetryableSimilarProductsError(error); } function getSimilarProductsRetryAttempts() { return normalizePositiveInteger( process.env.SELECTION_SIMILAR_PRODUCTS_RETRY_ATTEMPTS, DEFAULT_SIMILAR_PRODUCTS_RETRY_ATTEMPTS, MAX_SIMILAR_PRODUCTS_RETRY_ATTEMPTS ); } function waitForSimilarProductsRetry(attempt) { const delayMs = Math.min(2000, 300 * (2 ** Math.max(0, attempt - 1))); return new Promise((resolve) => setTimeout(resolve, delayMs)); } function normalizeSimilarCandidate(raw = {}, index = 0) { const explicitSpuCode = normalizeText( raw.spuCode || raw.spu_code || raw.primarySpu || raw.primary_spu, 160 ).toUpperCase(); const productCode = normalizeText(raw.productCode || raw.product_code, 160).toUpperCase(); const spuCode = explicitSpuCode || productCode; const skuCode = normalizeText(raw.maySkuCode || raw.may_sku_code || raw.skuCode || raw.sku_code, 160); return { rank: Number(raw.rank || index + 1), score: normalizeDecimal(raw.score), spuCode, productCode: productCode || spuCode, spuCodeSource: explicitSpuCode ? 'spu-code' : productCode ? 'product-code' : '', skuCode, maySkuCode: skuCode, skuName: normalizeText(raw.skuName || raw.sku_name || raw.productName || raw.product_name, 255), skuSize: normalizeText(raw.skuSize || raw.sku_size || raw.size, 255), skuImageUrl: normalizeImageUrl(raw.skuImageUrl || raw.sku_image_url || raw.imageUrl || raw.image_url), colorName: normalizeText(raw.colorName || raw.color_name || raw.color, 120), category: normalizeText(raw.category || raw.categoryName || raw.category_name, 255), brand: normalizeText(raw.brand || raw.brandName || raw.brand_name, 120), saleStatus: normalizeText(raw.saleStatus || raw.sale_status, 120), source: normalizeText(raw.source || raw.sourceTable || raw.source_table, 120), raw, }; } function normalizeCategoryConstraint(source = {}) { return { channelName: normalizeText(source.channelName || source.channel_name, 80), level1: normalizeText(source.categoryLevel1 || source.category_level1, 255), level2: normalizeText(source.categoryLevel2 || source.category_level2, 255), level3: normalizeText(source.categoryLevel3 || source.category_level3, 255), }; } function getCategoryMatchLevel(category, constraint = {}) { if (!constraint.level1 && !constraint.level2 && !constraint.level3) return 0; const text = normalizeText(category, 500).toLowerCase(); if (!text) return 0; if (constraint.level3 && text.includes(constraint.level3.toLowerCase())) return 3; if (constraint.level2 && text.includes(constraint.level2.toLowerCase())) return 2; if (constraint.level2 || constraint.level3) return -1; if (constraint.level1 && text.includes(constraint.level1.toLowerCase())) return 1; return -1; } function rankSpuCandidatesByCategory(candidates = [], constraint = {}) { return [...candidates] .map((candidate) => ({ ...candidate, categoryMatchLevel: getCategoryMatchLevel(candidate.category, constraint), })) .sort((a, b) => ( Number(b.categoryMatchLevel || 0) - Number(a.categoryMatchLevel || 0) || Number(b.bestScore || 0) - Number(a.bestScore || 0) )) .slice(0, COMPANY_SPU_CANDIDATE_LIMIT); } async function queryCompanySpuSnapshotMatches(spuCodes = []) { if (spuCodes.length === 0) return []; return planningDatabase.query( `WITH latest_snapshot AS ( SELECT MAX(stat_date) AS stat_date FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} WHERE NULLIF(TRIM(spu_code), '') IS NOT NULL ) SELECT UPPER(TRIM(p.spu_code)) AS spuCode, COUNT(DISTINCT p.may_sku_code) AS skuCount FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date WHERE NULLIF(TRIM(p.spu_code), '') IS NOT NULL AND NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL AND UPPER(TRIM(p.spu_code)) IN (${spuCodes.map(() => '?').join(', ')}) GROUP BY UPPER(TRIM(p.spu_code))`, spuCodes ); } async function validateCompanySpuCandidates(candidates = [], options = {}) { const byCode = new Map(); candidates.forEach((candidate) => { const spuCode = normalizeText(candidate?.spuCode, 160).toUpperCase(); if (!spuCode || byCode.has(spuCode)) return; byCode.set(spuCode, { ...candidate, spuCode }); }); const normalizedCandidates = Array.from(byCode.values()); if (normalizedCandidates.length === 0) { return { validCandidates: [], invalidCandidates: [], unverifiedCandidates: [], warnings: [] }; } const querySnapshot = options.querySnapshot || queryCompanySpuSnapshotMatches; const fetchSpuSkus = options.fetchSpuSkus || ((spuCode) => ( fetchCompanySkusFromJstApi(spuCode, MAX_SKU_QUERY_LIMIT, { includeInactive: true }) )); const verifiedByCode = new Map(); const warnings = []; const preverified = options.trustPreverified === true ? normalizedCandidates.filter((candidate) => candidate.spuVerified === true) : []; preverified.forEach((candidate) => { verifiedByCode.set(candidate.spuCode, { ...candidate, spuVerified: true, spuVerificationSource: candidate.spuVerificationSource || 'company-snapshot', }); }); const pendingSnapshotCodes = normalizedCandidates .map((candidate) => candidate.spuCode) .filter((spuCode) => !verifiedByCode.has(spuCode)); try { const snapshotRows = await querySnapshot(pendingSnapshotCodes); snapshotRows.forEach((row) => { const spuCode = normalizeText(row.spuCode || row.spu_code, 160).toUpperCase(); const candidate = byCode.get(spuCode); const skuCount = Number(row.skuCount || row.sku_count || 0); if (!candidate || skuCount < 1) return; verifiedByCode.set(spuCode, { ...candidate, skuCount: Math.max(Number(candidate.skuCount) || 0, skuCount), spuVerified: true, spuVerificationSource: 'company-snapshot', }); }); } catch (error) { warnings.push(`公司商品快照校验失败:${error.message || '未知错误'}`); } const invalidByCode = new Map(); const unverifiedByCode = new Map(); const realtimeErrors = []; const realtimeCandidates = normalizedCandidates.filter((candidate) => !verifiedByCode.has(candidate.spuCode)); for (let index = 0; index < realtimeCandidates.length; index += 8) { const batch = realtimeCandidates.slice(index, index + 8); const results = await Promise.all(batch.map(async (candidate) => { try { const rows = await fetchSpuSkus(candidate.spuCode); const exactRows = (Array.isArray(rows) ? rows : []).filter((row) => ( normalizeText(row.spuCode || row.spu_code, 160).toUpperCase() === candidate.spuCode )); return { candidate, rows: exactRows }; } catch (error) { return { candidate, error }; } })); results.forEach(({ candidate, rows, error }) => { if (error) { unverifiedByCode.set(candidate.spuCode, candidate); realtimeErrors.push(error); return; } if (rows.length === 0) { invalidByCode.set(candidate.spuCode, candidate); return; } verifiedByCode.set(candidate.spuCode, { ...candidate, skuCount: Math.max(Number(candidate.skuCount) || 0, rows.length), spuVerified: true, spuVerificationSource: 'getJstSkusBySpu', }); }); if (results.every((result) => result.error)) { realtimeCandidates.slice(index + batch.length).forEach((candidate) => { unverifiedByCode.set(candidate.spuCode, candidate); }); break; } } if (unverifiedByCode.size > 0) { warnings.push( `${unverifiedByCode.size} 个 SPU 实时校验失败:${realtimeErrors[0]?.message || '公司 SKU 接口不可用'}` ); } return { validCandidates: normalizedCandidates .map((candidate) => verifiedByCode.get(candidate.spuCode)) .filter(Boolean), invalidCandidates: normalizedCandidates.filter((candidate) => invalidByCode.has(candidate.spuCode)), unverifiedCandidates: normalizedCandidates.filter((candidate) => unverifiedByCode.has(candidate.spuCode)), warnings, }; } function getCompanyCategoryTerms(constraint = {}) { const level2 = normalizeText(constraint.level2, 255); const level2Alias = level2.endsWith('类') && level2.length > 1 ? level2.slice(0, -1) : ''; return [constraint.level3, level2, level2Alias] .map((value) => normalizeText(value, 255)) .filter((value, index, values) => Boolean(value) && values.indexOf(value) === index); } async function supplementCompanySpuCandidates(candidates = [], constraint = {}, limit = COMPANY_SPU_CANDIDATE_LIMIT) { const targetLimit = normalizePositiveInteger(limit, COMPANY_SPU_CANDIDATE_LIMIT, COMPANY_SPU_CANDIDATE_LIMIT); if (candidates.length >= targetLimit) return candidates.slice(0, targetLimit); const categoryTerms = getCompanyCategoryTerms(constraint); if (categoryTerms.length === 0) return candidates.slice(0, targetLimit); const conditions = categoryTerms.flatMap(() => [ "COALESCE(p.category_name, '') LIKE ?", "COALESCE(p.product_name, '') LIKE ?", ]); const params = categoryTerms.flatMap((term) => [`%${term}%`, `%${term}%`]); const rows = await planningDatabase.query( `WITH latest_snapshot AS ( SELECT MAX(stat_date) AS stat_date FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} WHERE NULLIF(TRIM(spu_code), '') IS NOT NULL ) SELECT UPPER(TRIM(p.spu_code)) AS spuCode, MAX(NULLIF(TRIM(COALESCE(p.product_name, p.sku_name)), '')) AS spuName, MAX(NULLIF(TRIM(p.category_name), '')) AS category, MAX(NULLIF(TRIM(p.may_sku_image_url), '')) AS imageUrl, MAX(NULLIF(TRIM(p.may_sales_status), '')) AS saleStatus, COUNT(DISTINCT p.may_sku_code) AS skuCount, SUM(COALESCE(p.may_performance_amount, 0)) AS salesAmount FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date WHERE NULLIF(TRIM(p.spu_code), '') IS NOT NULL AND NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL AND (${conditions.join(' OR ')}) GROUP BY UPPER(TRIM(p.spu_code)) ORDER BY CASE WHEN MAX(NULLIF(TRIM(p.may_sku_image_url), '')) IS NULL THEN 1 ELSE 0 END, SUM(COALESCE(p.may_performance_amount, 0)) DESC, spuCode ASC LIMIT 200`, params ); const rankedRows = [...rows].sort((left, right) => { const getCategoryRank = (row) => { const searchableText = `${normalizeText(row.category, 255)} ${normalizeText(row.spuName, 255)}`; const matchedIndex = categoryTerms.findIndex((term) => searchableText.includes(term)); return matchedIndex < 0 ? categoryTerms.length : matchedIndex; }; const categoryRank = getCategoryRank(left) - getCategoryRank(right); if (categoryRank !== 0) return categoryRank; return Number(right.salesAmount || 0) - Number(left.salesAmount || 0); }); const merged = [...candidates]; const existingCodes = new Set(candidates.map((candidate) => normalizeText(candidate.spuCode, 160).toUpperCase())); for (const row of rankedRows) { const spuCode = normalizeText(row.spuCode, 160).toUpperCase(); if (!spuCode || existingCodes.has(spuCode)) continue; existingCodes.add(spuCode); merged.push({ spuCode, spuName: normalizeText(row.spuName, 255), bestScore: null, category: normalizeText(row.category, 255) || categoryTerms.join(' / '), brand: '', imageUrl: normalizeImageUrl(row.imageUrl), saleStatus: normalizeText(row.saleStatus, 120), skuCount: normalizePositiveInteger(row.skuCount, 1), candidates: [], matchSource: 'company-category-supplement', spuVerified: true, spuVerificationSource: 'company-snapshot', }); if (merged.length >= targetLimit) break; } return merged.slice(0, targetLimit); } function fillMonthlySalesTrend6m(rows = []) { const values = new Map(); rows.forEach((row) => { const month = normalizeText(row?.month || row?.salesMonth, 7); if (/^\d{4}-\d{2}$/.test(month)) { values.set(month, normalizeDecimal(row?.salesAmount) || 0); } }); const latestMonth = [...values.keys()].sort().at(-1); if (!latestMonth) return []; const [year, month] = latestMonth.split('-').map(Number); return Array.from({ length: 6 }, (_, index) => { const date = new Date(Date.UTC(year, month - 1 - (5 - index), 1)); const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return { month: key, salesAmount: values.get(key) || 0, }; }); } async function enrichCompanySpuCandidates(candidates = []) { const spuCodes = Array.from(new Set( candidates.map((candidate) => normalizeText(candidate.spuCode, 160).toUpperCase()).filter(Boolean) )).slice(0, 100); if (spuCodes.length === 0) return candidates; const candidateSpuCte = spuCodes.map((_, index) => ( index === 0 ? 'SELECT ? AS spuCode' : 'UNION ALL SELECT ?' )).join('\n'); const enrichmentResults = await Promise.allSettled([ planningDatabase.query( `WITH candidate_spus AS ( ${candidateSpuCte} ), latest_snapshot AS ( SELECT MAX(stat_date) AS stat_date FROM planning_may_product_snapshot_di ) SELECT candidate_spus.spuCode, MAX(NULLIF(TRIM(p.may_core_material), '')) AS material FROM candidate_spus INNER JOIN planning_may_product_snapshot_di p ON UPPER(TRIM(COALESCE(NULLIF(p.spu_code, ''), p.product_code))) LIKE CONCAT(candidate_spus.spuCode, '%') INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date GROUP BY candidate_spus.spuCode`, spuCodes ), planningDatabase.query( `WITH candidate_spus AS ( ${candidateSpuCte} ), latest_sales_source AS ( SELECT MAX(stat_date) AS stat_date FROM channel_id_daily_read ), recent_sales_rows AS ( SELECT COALESCE( NULLIF(REGEXP_SUBSTR( UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), '^[A-Z]+[0-9]{4}' ), ''), UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) ) COLLATE utf8mb4_unicode_ci AS companySpuCode, d.stat_date AS statDate, COALESCE(d.transaction_amount, 0) AS transactionAmount FROM channel_id_daily_read d INNER JOIN latest_sales_source latest ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL ), latest_operation_source AS ( SELECT MAX(stat_date) AS stat_date FROM channel_daily_read ), recent_operation_rows AS ( SELECT UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))) COLLATE utf8mb4_unicode_ci AS rawSpuCode, COALESCE( NULLIF(REGEXP_SUBSTR( UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))), '^[A-Z]+[0-9]{4}' ), ''), UPPER(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code))) ) COLLATE utf8mb4_unicode_ci AS companySpuCode, d.stat_date AS statDate, COALESCE(d.after_sale_amount, 0) AS afterSaleAmount, COALESCE(d.ship_count, 0) AS shipCount FROM channel_daily_read d INNER JOIN latest_operation_source latest ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date WHERE NULLIF(TRIM(COALESCE(NULLIF(d.spu_key, ''), NULLIF(d.spu, ''), d.sku_code)), '') IS NOT NULL ), -- 先按 SPU 聚合成小表再 JOIN 候选:直接让候选(100行) JOIN 40万行正则派生表时 -- MariaDB 走嵌套循环,REGEXP_SUBSTR 被重复执行约4000万次,实测23秒以上不返回。 spu_sales_30d AS ( SELECT companySpuCode, COUNT(statDate) AS salesMetricRowCount, ROUND(SUM(COALESCE(transactionAmount, 0)), 2) AS salesAmount30d FROM recent_sales_rows GROUP BY companySpuCode ), spu_operations_30d AS ( SELECT companySpuCode, COUNT(statDate) AS operationMetricRowCount, COUNT(DISTINCT statDate) AS operationDayCount, ROUND(SUM(COALESCE(afterSaleAmount, 0)), 2) AS afterSaleAmount30d, ROUND(SUM(COALESCE(shipCount, 0)), 2) AS shipmentCount30d FROM recent_operation_rows WHERE rawSpuCode <> companySpuCode GROUP BY companySpuCode ), candidate_sales AS ( SELECT candidate_spus.spuCode, COALESCE(spu_sales_30d.salesMetricRowCount, 0) AS salesMetricRowCount, COALESCE(spu_sales_30d.salesAmount30d, 0) AS salesAmount30d FROM candidate_spus LEFT JOIN spu_sales_30d ON spu_sales_30d.companySpuCode = candidate_spus.spuCode ), candidate_operations AS ( SELECT candidate_spus.spuCode, COALESCE(spu_operations_30d.operationMetricRowCount, 0) AS operationMetricRowCount, COALESCE(spu_operations_30d.operationDayCount, 0) AS operationDayCount, COALESCE(spu_operations_30d.afterSaleAmount30d, 0) AS afterSaleAmount30d, COALESCE(spu_operations_30d.shipmentCount30d, 0) AS shipmentCount30d FROM candidate_spus LEFT JOIN spu_operations_30d ON spu_operations_30d.companySpuCode = candidate_spus.spuCode ) SELECT candidate_sales.spuCode, candidate_sales.salesMetricRowCount, candidate_operations.operationMetricRowCount, candidate_operations.operationDayCount, candidate_sales.salesAmount30d, candidate_operations.afterSaleAmount30d, candidate_operations.shipmentCount30d, ROUND( candidate_operations.afterSaleAmount30d * 100 / NULLIF(candidate_sales.salesAmount30d, 0), 2 ) AS avgAfterSalesRatio30d, ROUND( candidate_operations.shipmentCount30d / NULLIF(candidate_operations.operationDayCount, 0), 2 ) AS avgDailyShipment30d FROM candidate_sales INNER JOIN candidate_operations ON candidate_operations.spuCode = candidate_sales.spuCode`, spuCodes ), planningDatabase.query( `WITH candidate_spus AS ( ${candidateSpuCte} ), latest_source AS ( SELECT MAX(stat_date) AS stat_date FROM channel_id_daily_read ), normalized_rows AS ( SELECT COALESCE( NULLIF(REGEXP_SUBSTR( UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), '^[A-Z]+[0-9]{4}' ), ''), UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) ) COLLATE utf8mb4_unicode_ci AS companySpuCode, d.stat_date AS statDate, COALESCE(d.transaction_amount, 0) AS transactionAmount FROM channel_id_daily_read d INNER JOIN latest_source latest ON d.stat_date >= DATE_FORMAT(DATE_SUB(latest.stat_date, INTERVAL 5 MONTH), '%Y-%m-01') AND d.stat_date <= latest.stat_date WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL ), -- 同经营指标查询:先按 SPU+月份聚合成小表再 JOIN 候选,避免 200 万行正则派生表 -- 被嵌套循环重复计算。 spu_monthly_sales AS ( SELECT companySpuCode, DATE_FORMAT(statDate, '%Y-%m') AS salesMonth, SUM(transactionAmount) AS salesAmount FROM normalized_rows GROUP BY companySpuCode, DATE_FORMAT(statDate, '%Y-%m') ) SELECT candidate_spus.spuCode, spu_monthly_sales.salesMonth, spu_monthly_sales.salesAmount FROM candidate_spus INNER JOIN spu_monthly_sales ON spu_monthly_sales.companySpuCode = candidate_spus.spuCode ORDER BY candidate_spus.spuCode, spu_monthly_sales.salesMonth`, spuCodes ), planningDatabase.query( `WITH candidate_spus AS ( ${candidateSpuCte} ) SELECT candidate_spus.spuCode, COUNT(DISTINCT NULLIF(TRIM(j.sku_id), '')) AS finishedSkuCount, MIN( CASE WHEN REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') <> '停产' THEN COALESCE(NULLIF(j.cost_price, 0), NULLIF(j.purchase_price, 0)) ELSE NULL END ) AS minCost, MAX( CASE WHEN REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') <> '停产' THEN COALESCE(NULLIF(j.cost_price, 0), NULLIF(j.purchase_price, 0)) ELSE NULL END ) AS maxCost FROM candidate_spus LEFT JOIN jst_sku j ON UPPER(TRIM(j.i_id)) COLLATE utf8mb4_unicode_ci = candidate_spus.spuCode AND REPLACE(COALESCE(NULLIF(TRIM(j.item_type), ''), ''), '''', '') = '成品' GROUP BY candidate_spus.spuCode`, spuCodes ), planningDatabase.query( `WITH latest_sales_source AS ( SELECT MAX(stat_date) AS stat_date FROM channel_id_daily_read ), recent_sales_rows AS ( SELECT COALESCE( NULLIF(REGEXP_SUBSTR( UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))), '^[A-Z]+[0-9]{4}' ), ''), UPPER(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code))) ) COLLATE utf8mb4_unicode_ci AS companySpuCode, COALESCE(d.transaction_amount, 0) AS transactionAmount FROM channel_id_daily_read d INNER JOIN latest_sales_source latest ON d.stat_date BETWEEN DATE_SUB(latest.stat_date, INTERVAL 29 DAY) AND latest.stat_date WHERE NULLIF(TRIM(COALESCE(NULLIF(d.primary_spu, ''), d.primary_sku_code)), '') IS NOT NULL ), active_company_spus AS ( SELECT DISTINCT UPPER(TRIM(j.i_id)) COLLATE utf8mb4_unicode_ci AS companySpuCode FROM jst_sku j WHERE NULLIF(TRIM(j.i_id), '') IS NOT NULL AND REPLACE(COALESCE(NULLIF(TRIM(j.other_5), ''), ''), '''', '') = '销售' AND REPLACE(COALESCE(NULLIF(TRIM(j.item_type), ''), ''), '''', '') = '成品' AND ( NULLIF(TRIM(j.category), '') IS NULL OR TRIM(j.category) NOT LIKE '%配件%' ) ) SELECT recent.companySpuCode AS spuCode, SUM(recent.transactionAmount) AS salesAmount30d FROM recent_sales_rows recent INNER JOIN active_company_spus active ON active.companySpuCode = recent.companySpuCode GROUP BY recent.companySpuCode HAVING SUM(recent.transactionAmount) > 0`, [] ), ]); const enrichmentLabels = ['企划快照', '订单经营指标', '月度销售趋势', 'ERP 成品主档', 'SPU销售排名池']; enrichmentResults.forEach((result, index) => { if (result.status === 'rejected') { console.warn( `公司 SPU 候选${enrichmentLabels[index]}查询失败:${result.reason?.message || '未知错误'}` ); } }); const [snapshotRows, performanceRows, trendRows, productMasterRows, salesRankRows] = enrichmentResults.map((result) => ( result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [] )); // 排名改在应用层计算:原 SQL 对 100 个候选逐行执行相关子查询,实测单次匹配拖慢 2.5 分钟以上。 const salesRankPool = salesRankRows .map((row) => Number(row.salesAmount30d)) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => b - a); const rankedSpuTotal = salesRankPool.length; const companySalesRankBySpu = new Map(); salesRankRows.forEach((row) => { const key = normalizeText(row.spuCode).toUpperCase(); const amount = Number(row.salesAmount30d); if (key && Number.isFinite(amount) && amount > 0) { companySalesRankBySpu.set(key, 1 + salesRankPool.findIndex((value) => value <= amount)); } }); const snapshots = new Map(snapshotRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); const performance = new Map(performanceRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); const productMasters = new Map(productMasterRows.map((row) => [normalizeText(row.spuCode).toUpperCase(), row])); const trends = new Map(); trendRows.forEach((row) => { const key = normalizeText(row.spuCode).toUpperCase(); if (!trends.has(key)) trends.set(key, []); trends.get(key).push({ month: normalizeText(row.salesMonth, 20), salesAmount: normalizeDecimal(row.salesAmount) || 0, }); }); const enriched = candidates.map((candidate) => { const key = normalizeText(candidate.spuCode, 160).toUpperCase(); const snapshot = snapshots.get(key) || {}; const sales = performance.get(key) || {}; const productMaster = productMasters.get(key) || {}; return { ...candidate, skuCount: Math.max( Number(candidate.skuCount) || 0, normalizePositiveInteger(productMaster.finishedSkuCount, 0) ), operatingMetrics: { material: normalizeText(snapshot.material, 255), minCost: normalizeNullableDecimal(productMaster.minCost), maxCost: normalizeNullableDecimal(productMaster.maxCost), avgAfterSalesRatio30d: normalizeNullableDecimal(sales.avgAfterSalesRatio30d), salesAmount30d: Number(sales.salesMetricRowCount) > 0 ? normalizeDecimal(sales.salesAmount30d) : null, companySalesRank30d: normalizePositiveInteger(companySalesRankBySpu.get(key), 0), rankedSpuTotal: normalizePositiveInteger(rankedSpuTotal, 0), avgDailyShipment30d: Number(sales.operationMetricRowCount) > 0 ? normalizeDecimal(sales.avgDailyShipment30d) : null, metricWindowDayCount: normalizePositiveInteger(sales.operationDayCount, 0), salesMetricLabel: '订单维度近30日成交金额(按标准 SPU 精确归集)', afterSalesMetricLabel: '近30日售后金额 / 同期成交金额', rankingMetricLabel: '当前在售成品 SPU,按订单维度近30日成交金额排名', shipmentMetricLabel: `发货件数 / 实际有记录天数(${normalizePositiveInteger(sales.operationDayCount, 0) || '--'} 天)`, skuMetricLabel: 'ERP 成品 SKU 数(含销售、退市和停产状态)', costMetricLabel: 'ERP 成品 SKU 成本区间(停产 SKU 不计)', monthlySalesTrend6m: fillMonthlySalesTrend6m(trends.get(key) || []), }, }; }); // 全量 100 个逐批实时拉取实测要 1-2 分钟;视觉命中的候选前序步骤已要求校验通过, // 这里只补全缺少名称/图片/成本的候选,品类补足候选多数已有快照资料。 const candidatesNeedingDetails = enriched.filter((candidate) => { const metrics = candidate.operatingMetrics || {}; return !candidate.spuName || !candidate.imageUrl || (metrics.minCost === null && metrics.minCost === undefined); }); return hydrateCompanySpuCandidateDetails(candidatesNeedingDetails).then(() => enriched); } async function hydrateCompanySpuCandidateDetails(candidates = [], loadSkus = getCompanySkusBySpu) { const needsRealtimeDetails = candidates; for (let index = 0; index < needsRealtimeDetails.length; index += 8) { const batch = needsRealtimeDetails.slice(index, index + 8); const details = await Promise.all(batch.map(async (candidate) => { try { const result = await loadSkus(candidate.spuCode, { limit: MAX_SKU_QUERY_LIMIT, includeInactive: true, }); return [ candidate.spuCode, Array.isArray(result?.rows) ? result.rows : [], normalizeText(result?.source, 120), ]; } catch (error) { console.warn(`公司 SPU 候选资料补全失败 (${candidate.spuCode}):${error.message || '未知错误'}`); return [candidate.spuCode, [], '']; } })); const detailsBySpu = new Map(details.map(([spuCode, rows, source]) => [spuCode, { rows, source }])); batch.forEach((candidate) => { const detail = detailsBySpu.get(candidate.spuCode) || { rows: [], source: '' }; const { rows, source } = detail; if (rows.length === 0) return; const isRealtimeJstSource = !source || source === 'getJstSkusBySpu'; const costEligibleRows = rows.filter((row) => normalizeText(row.saleStatus, 40) !== '停产'); const costs = costEligibleRows .map((row) => row.cost) .filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))) .map(Number); const material = rows.map((row) => row.material).find(Boolean); candidate.imageUrl = candidate.imageUrl || rows.map((row) => row.skuImageUrl).find(Boolean) || ''; candidate.spuName = candidate.spuName || rows.map((row) => row.productName || row.skuName).find(Boolean) || candidate.spuCode; candidate.skuCount = Math.max(Number(candidate.skuCount) || 0, rows.length); const metrics = candidate.operatingMetrics || (candidate.operatingMetrics = {}); metrics.material = metrics.material || material || ''; metrics.realtimeSkuCount = isRealtimeJstSource ? rows.length : 0; metrics.realtimeCostSkuCount = isRealtimeJstSource ? costs.length : 0; if (isRealtimeJstSource && costs.length > 0) { metrics.minCost = Math.min(...costs); metrics.maxCost = Math.max(...costs); metrics.costSource = 'getJstSkusBySpu'; metrics.costMetricLabel = `聚水潭实时接口成本区间(销售/退市 SKU,停产 SKU 不计;${costs.length} 条有成本)`; } }); } return candidates; } function normalizeSimilarResponsePayload(payload = {}, categoryConstraint = {}) { const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload; const ownProductMatches = Array.isArray(data?.ownProductMatches) ? data.ownProductMatches : Array.isArray(data?.own_product_matches) ? data.own_product_matches : []; const candidates = ownProductMatches .map(normalizeSimilarCandidate) .filter((candidate) => candidate.spuCode); const grouped = new Map(); for (const candidate of candidates) { const spuKey = candidate.spuCode; const existing = grouped.get(spuKey); if (!existing) { grouped.set(spuKey, { spuCode: spuKey, spuName: candidate.skuName, bestScore: candidate.score, category: candidate.category, brand: candidate.brand, imageUrl: candidate.skuImageUrl, saleStatus: candidate.saleStatus, skuCount: 1, candidates: [candidate], }); } else { existing.skuCount += 1; existing.candidates.push(candidate); if (candidate.score !== null && (existing.bestScore === null || candidate.score > existing.bestScore)) { existing.bestScore = candidate.score; existing.spuName = candidate.skuName || existing.spuName; existing.imageUrl = candidate.skuImageUrl || existing.imageUrl; existing.saleStatus = candidate.saleStatus || existing.saleStatus; } } } return { marketMatches: data?.marketMatches || data?.market_matches || [], matchedSpus: data?.matchedSpus || data?.matched_spus || [], marketProductLinks: data?.marketProductLinks || data?.market_product_links || [], ownProductMatches: candidates, spuCandidates: rankSpuCandidatesByCategory(Array.from(grouped.values()), categoryConstraint), categoryConstraint, raw: payload, }; } function parseCompanySkuMarkdown(answer) { const rows = []; for (const line of String(answer || '').split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue; const columns = trimmed .slice(1, -1) .split('|') .map((value) => value.trim()); if (columns.length < 5 || !/^\d+$/.test(columns[0])) continue; const rank = Number(columns[0]); const skuCode = normalizeText(columns[1], 160); const skuName = normalizeText(columns[2], 255); const spuCode = normalizeText(columns[3], 160); const score = normalizeDecimal(columns[4]); const imageUrl = normalizeImageUrl( (columns.slice(5).join('|').match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1] ); if (!skuCode && !spuCode) continue; rows.push({ rank, maySkuCode: skuCode, skuCode, skuName, productCode: spuCode, spuCode, score, skuImageUrl: imageUrl, ...(normalizeText(columns[6], 255) ? { category: normalizeText(columns[6], 255) } : {}), source: 'image-search', }); } return rows; } function parseMarketReferenceMarkdown(answer) { const rows = []; for (const line of String(answer || '').split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue; const columns = trimmed .slice(1, -1) .split('|') .map((value) => value.trim()); if (columns.length < 6 || !/^\d+$/.test(columns[0])) continue; const marketItemId = normalizeText(columns[1], 160); if (!/^(?:[a-z]+:)?[a-z0-9_-]{6,}$/i.test(marketItemId)) continue; const detailUrl = normalizeText( (columns[3].match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1], 2000 ); const imageUrl = normalizeImageUrl( (columns[4].match(/\]\((https?:\/\/[^)]+)\)/i) || [])[1] ); if (!imageUrl) continue; rows.push({ rank: Number(columns[0]), marketItemId, itemId: marketItemId, title: normalizeText(columns[2], 500), detailUrl, mainImageUrl: imageUrl, imageUrl, score: normalizeDecimal(columns[5]), category: normalizeText(columns[6], 255), source: 'image-search', }); } return rows; } async function searchSimilarProductsWithOpenApi({ imageUrl, imageBase64, topK, scoreThreshold, includeRaw, categoryConstraint, }) { const config = await resolveSimilarProductsConfig(); if (!config.baseUrl || !config.token) { throw createExternalServiceError('相似款以图搜图 API 未配置,请配置 SELECTION_SIMILAR_PRODUCTS_TOKEN'); } const payload = { top_k: normalizePositiveInteger(topK, DEFAULT_SIMILAR_TOP_K, 100), include_raw: includeRaw !== undefined ? Boolean(includeRaw) : true, search_mode: 'appearance', category_level1: categoryConstraint?.level1 || undefined, category_level2: categoryConstraint?.level2 || undefined, category_level3: categoryConstraint?.level3 || undefined, category_strict: Boolean(categoryConstraint?.level2 || categoryConstraint?.level3), }; if (scoreThreshold !== undefined && scoreThreshold !== null && scoreThreshold !== '') { payload.score_threshold = Number(scoreThreshold); } else { payload.score_threshold = normalizeDecimal( process.env.SELECTION_COMPANY_SIMILAR_SCORE_THRESHOLD ) ?? COMPANY_SIMILAR_DEFAULT_SCORE_THRESHOLD; } if (imageUrl) payload.image_url = imageUrl; if (imageBase64) payload.image_base64 = imageBase64; if (!payload.image_url && !payload.image_base64) { const error = new Error('缺少用于以图搜图的图片'); error.status = 400; throw error; } const attempts = getSimilarProductsRetryAttempts(); for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await axios.post(config.endpointUrl, payload, { timeout: config.timeoutMs, headers: getSimilarProductsRequestHeaders(config.token), maxBodyLength: 15 * 1024 * 1024, }); return normalizeSimilarResponsePayload(response.data, categoryConstraint); } catch (error) { const requestError = createSimilarProductsRequestError(error, '公司相似款查询失败'); if (!requestError.retryable || attempt >= attempts) throw requestError; await waitForSimilarProductsRetry(attempt); } } throw createExternalServiceError('公司相似款查询失败', 502, 'SIMILAR_PRODUCTS_REQUEST_FAILED'); } function normalizeMarketReferenceCandidate(row = {}, index = 0) { const marketItemId = normalizeText( row.marketItemId || row.market_item_id || row.productId || row.product_id || row.goodsId || row.goods_id || row.itemId || row.item_id || row.id, 160 ); const imageUrl = normalizeImageUrl( row.mainImageUrl || row.main_image_url || row.imageUrl || row.image_url || row.coverUrl || row.cover_url || row.picUrl || row.pic_url || row.image ); if (!marketItemId || !imageUrl) return null; const channelName = inferMarketChannelName( row.channelName || row.channel_name || row.sourceChannel || row.source_channel || row.platform || row.channel, marketItemId ); const price = normalizeNullableDecimal( row.price ?? row.pagePrice ?? row.page_price ?? row.listPrice ?? row.list_price ?? row.actualHandPrice ?? row.actual_hand_price ?? row.lowestListPrice ?? row.lowest_list_price ); const storedPriceBandList = normalizePriceBandList( row.priceBandList, row.price_band_list, row.priceBand, row.price_band, row.priceRange, row.price_range ); const priceBandList = storedPriceBandList.length > 0 ? storedPriceBandList : normalizePriceBandList(buildMarketReferencePriceBand(price)); return { id: `${channelName || 'market'}:${marketItemId}`, marketItemId, channelName, title: normalizeText(row.title || row.productTitle || row.product_title || row.name, 500), brandName: normalizeText(row.brandName || row.brand_name || row.brand, 120), shopName: normalizeText(row.shopName || row.shop_name || row.storeName || row.store_name, 255), imageUrl, detailUrl: normalizeText( row.detailUrl || row.detail_url || row.productUrl || row.product_url || row.url, 2000 ), priceBand: priceBandList[0] || '', priceBandList, skuPriceRange: normalizeText(row.skuPriceRange || row.sku_price_range || row.analysisPriceRange, 120), price, salesVolume: normalizeText( row.salesVolume || row.sales_volume || row.analysisSales || row.analysis_sales || row.totalSold || row.total_sold || row.sales, 120 ), category: normalizeText(row.category || row.categoryName || row.category_name, 255), score: normalizeDecimal(row.score ?? row.similarity ?? row.distance), rank: Number(row.rank || index + 1), }; } function buildPreferredMarketReferenceSeed(value) { const candidate = normalizeMarketReferenceCandidate(value || {}); if (!candidate) return { candidate: null, batch: null }; return { candidate, batch: { source: 'alibaba_1688_sourcing_context', searchedAt: new Date().toISOString(), count: 1, candidates: [candidate], }, }; } function inferMarketChannelName(value, marketItemId = '') { const explicit = normalizeText(value, 80); if (explicit) return explicit; const prefix = normalizeText(marketItemId, 160).split(':')[0].toLowerCase(); const labels = { jd: '京东', '1688': '1688', douyin: '抖音', pdd: '拼多多', taobao: '淘宝', tmall: '天猫', }; return labels[prefix] || ''; } function buildMarketReferencePriceBand(value) { const price = normalizeNullableDecimal(value); if (price === null || price < 0) return ''; const bands = [10, 20, 30, 50, 100, 200, 300, 500, 1000, 2000]; const upper = bands.find((boundary) => price < boundary); if (!upper) return '2000+'; const lowerIndex = bands.indexOf(upper) - 1; return `${lowerIndex >= 0 ? bands[lowerIndex] : 0}-${upper}`; } function parseMarketSalesVolume(value) { const text = String(value ?? '').trim().replace(/,/g, ''); if (!text) return 0; const numeric = Number.parseFloat(text); if (!Number.isFinite(numeric)) return 0; if (/万/.test(text)) return numeric * 10000; if (/亿/.test(text)) return numeric * 100000000; return numeric; } function cosineSimilarity(left = [], right = []) { if (!Array.isArray(left) || !Array.isArray(right) || left.length === 0 || left.length !== right.length) return -1; let dot = 0; let leftNorm = 0; let rightNorm = 0; for (let index = 0; index < left.length; index += 1) { const leftValue = Number(left[index]) || 0; const rightValue = Number(right[index]) || 0; dot += leftValue * rightValue; leftNorm += leftValue * leftValue; rightNorm += rightValue * rightValue; } return leftNorm && rightNorm ? dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)) : -1; } // 阿里系 CDN 会返回 xxx.jpg_.webp 这类 WebP 派生地址,部分图搜服务对 WebP // 下载失败或特征质量下降;去掉 _.webp 后缀还原原始格式地址再发起搜索。 function toSearchableImageUrl(url) { return normalizeImageUrl(url).replace(/_\.webp(?=[?#]|$)/i, ''); } function filterStrictMarketReferenceCandidates(candidates = [], categoryConstraint = {}) { const threshold = normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5; const requiredCategoryMatchLevel = categoryConstraint.level3 ? 3 : (categoryConstraint.level2 ? 2 : 0); return candidates.filter((candidate) => { const score = Number(candidate.score); if (!Number.isFinite(score) || score < threshold) return false; if (requiredCategoryMatchLevel > 0 && Number(candidate.categoryMatchLevel || 0) < requiredCategoryMatchLevel) { return false; } if (requiredCategoryMatchLevel > 0 && hasObviousMarketCategoryConflict(candidate, categoryConstraint)) { return false; } return true; }); } function buildSelectionFissionPlanListSummary(plan = {}) { const sourceItems = (Array.isArray(plan.sourceItems) ? plan.sourceItems : []).map((item) => ({ id: item.id, sourceItemId: item.sourceItemId, slotNo: item.slotNo, marketItemId: item.marketItemId, channelName: item.channelName, title: item.title, mainImageUrl: item.mainImageUrl, workflowStep: item.workflowStep, workflowStatus: item.workflowStatus, workflowErrorMessage: item.workflowErrorMessage, })); const listingTasks = (Array.isArray(plan.listingTasks) ? plan.listingTasks : []).map((item) => ({ id: item.id, planId: item.planId, sourceItemId: item.sourceItemId, selectedSpuCode: item.selectedSpuCode, workflowStep: item.workflowStep, workflowStatus: item.workflowStatus, workflowErrorMessage: item.workflowErrorMessage, listingStatus: item.listingStatus, listedItemId: item.listedItemId, listingErrorCode: item.listingErrorCode, listingErrorMessage: item.listingErrorMessage, pddDraftCommitId: item.pddDraftCommitId, })); return { ...plan, items: [], sourceItems, listingTasks, detailLoaded: false, }; } function hasObviousMarketCategoryConflict(candidate = {}, categoryConstraint = {}) { const categoryText = [ categoryConstraint.level1, categoryConstraint.level2, categoryConstraint.level3, candidate.category, ].filter(Boolean).join(' ').toLowerCase(); const titleText = String(candidate.title || '').toLowerCase(); if (!categoryText || !titleText) return false; // Image-search providers occasionally return a visually similar but // functionally unrelated item. Reject only high-confidence cross-category // pairs; normal category matching remains the primary filter. const conflictGroups = [ { category: /鞋|靴|鞋柜|鞋架/, title: /桌面扩展板|显示器增高|键盘托|电脑支架|鼠标垫/ }, { category: /桌面扩展板|显示器增高|键盘托|电脑支架/, title: /鞋|靴|鞋柜|鞋架/ }, { category: /衣架|衣帽架|挂衣架/, title: /鞋架|鞋柜|桌面扩展板|显示器增高/ }, { category: /书架|置物架|收纳架/, title: /鞋靴护理|鞋油|鞋刷/ }, ]; return conflictGroups.some(({ category, title }) => category.test(categoryText) && title.test(titleText)); } async function supplementMarketReferenceCandidates(candidates = [], categoryConstraint = {}, limit = 50, imageUrl = '') { const unique = new Map(candidates.map((candidate) => [String(candidate.marketItemId), candidate])); const categoryName = categoryConstraint.level3 || categoryConstraint.level2 || categoryConstraint.level1; if (unique.size >= limit || !categoryName || !imageUrl) { return Array.from(unique.values()); } try { const [storedReference] = await planningDatabase.query( `SELECT embedding_vector_json AS embeddingVectorJson FROM taobao_product_records WHERE main_image_url = ? AND embedding_vector_json IS NOT NULL AND TRIM(embedding_vector_json) <> '' ORDER BY updated_at DESC LIMIT 1`, [imageUrl] ); let queryVector = safeJsonParse(storedReference?.embeddingVectorJson, []); if (!Array.isArray(queryVector) || queryVector.length === 0) { const queryEmbedding = await fetchTaobaoProductEmbedding(imageUrl, 120000); queryVector = queryEmbedding.vector; } if (!Array.isArray(queryVector) || queryVector.length === 0) { return Array.from(unique.values()); } const rows = await planningDatabase.query( `SELECT item_id AS marketItemId, source_channel AS sourceChannel, source_item_id AS sourceItemId, title, shop_name AS shopName, main_image_url AS imageUrl, price_band AS priceBand, sku_price_range AS skuPriceRange, analysis_sales AS salesVolume, price AS pagePrice, actual_hand_price AS actualHandPrice, category_level1 AS categoryLevel1, category_level2 AS categoryLevel2, category_level3 AS categoryLevel3, embedding_vector_json AS embeddingVectorJson FROM taobao_product_records WHERE main_image_url IS NOT NULL AND TRIM(main_image_url) <> '' AND (category_level1 = ? OR category_level2 = ? OR category_level3 = ?) AND embedding_vector_json IS NOT NULL AND TRIM(embedding_vector_json) <> '' ORDER BY updated_at DESC LIMIT ?`, [categoryName, categoryName, categoryName, MARKET_REFERENCE_SUPPLEMENT_SCAN_LIMIT] ); const rankedRows = rows.map((row) => { const vector = safeJsonParse(row.embeddingVectorJson, []); return { ...row, visualSimilarity: cosineSimilarity(queryVector, vector) }; }).filter((row) => row.visualSimilarity >= 0.5) .sort((left, right) => ( right.visualSimilarity - left.visualSimilarity || parseMarketSalesVolume(right.salesVolume) - parseMarketSalesVolume(left.salesVolume) )); for (const [index, row] of rankedRows.entries()) { if (unique.size >= limit) break; const candidate = normalizeMarketReferenceCandidate({ ...row, channelName: row.sourceChannel || categoryConstraint.channelName, marketItemId: row.marketItemId || row.sourceItemId, price: row.pagePrice ?? row.actualHandPrice, category: [row.categoryLevel1, row.categoryLevel2, row.categoryLevel3].filter(Boolean).join(' / '), score: row.visualSimilarity, rank: candidates.length + index + 1, }, candidates.length + index); if (candidate && !unique.has(String(candidate.marketItemId))) { unique.set(String(candidate.marketItemId), { ...candidate, matchSource: 'category_visual_embedding' }); } } } catch (error) { console.warn('补充市场同品类视觉相似商品失败:', error.message); } return Array.from(unique.values()); } function buildMarketProductDetailUrl(candidate = {}) { if (candidate.detailUrl) return candidate.detailUrl; const itemId = encodeURIComponent(normalizeMarketItemLookupId(candidate.marketItemId || '')); const channel = String(candidate.channelName || '').toLowerCase(); if (!itemId) return ''; if (channel.includes('淘宝') || channel.includes('天猫') || channel.includes('taobao') || channel.includes('tmall')) { return `https://item.taobao.com/item.htm?id=${itemId}`; } if (channel.includes('京东') || channel.includes('jd')) return `https://item.jd.com/${itemId}.html`; if (channel.includes('1688')) return `https://detail.1688.com/offer/${itemId}.html`; if (channel.includes('拼多多') || channel.includes('pdd')) return `https://mobile.yangkeduo.com/goods.html?goods_id=${itemId}`; if (channel.includes('抖音') || channel.includes('douyin')) return `https://haohuo.jinritemai.com/views/product/item2?id=${itemId}`; return ''; } async function enrichMarketReferenceCandidates(candidates = [], categoryConstraint = {}) { const itemIds = Array.from(new Set(candidates.map((row) => row.marketItemId).filter(Boolean))).slice(0, 100); const metricsByItemId = new Map(); let canonicalPriceBandsByItemId = new Map(); if (itemIds.length > 0) { try { canonicalPriceBandsByItemId = await loadCanonicalMarketPriceBands(itemIds); const placeholders = itemIds.map(() => '?').join(', '); const rows = await planningDatabase.query( `SELECT item_id AS itemId, source_channel AS sourceChannel, source_item_id AS sourceItemId, price_band AS priceBand, sku_price_range AS skuPriceRange, analysis_sales AS analysisSales, price AS pagePrice, actual_hand_price AS actualHandPrice, lowest_list_price AS lowestListPrice, category_level1 AS categoryLevel1, category_level2 AS categoryLevel2, category_level3 AS categoryLevel3 FROM taobao_product_records WHERE item_id IN (${placeholders}) ORDER BY updated_at DESC`, itemIds ); for (const row of rows) { if (!metricsByItemId.has(String(row.itemId))) metricsByItemId.set(String(row.itemId), row); } } catch (error) { console.warn('补充市场参考款价格销量失败:', error.message); } } return candidates.map((candidate) => { const metrics = metricsByItemId.get(String(candidate.marketItemId)) || {}; const channelName = inferMarketChannelName(metrics.sourceChannel || candidate.channelName, candidate.marketItemId); const price = normalizeNullableDecimal( metrics.pagePrice ?? candidate.price ?? metrics.actualHandPrice ?? metrics.lowestListPrice ); const canonicalPriceBandList = canonicalPriceBandsByItemId.get(String(candidate.marketItemId)) || canonicalPriceBandsByItemId.get(normalizeMarketItemLookupId(candidate.marketItemId)) || []; const priceBandList = canonicalPriceBandList.length > 0 ? canonicalPriceBandList : normalizePriceBandList( candidate.priceBandList, candidate.priceBand, metrics.priceBand, buildMarketReferencePriceBand(price) ); const enriched = { ...candidate, channelName, priceBand: priceBandList[0] || '', priceBandList, priceBandSource: canonicalPriceBandList.length > 0 ? MARKET_RANK_TABLE : 'detail_fallback', skuPriceRange: candidate.skuPriceRange || normalizeText(metrics.skuPriceRange, 120), price, salesVolume: candidate.salesVolume || normalizeText(metrics.analysisSales, 120), category: candidate.category || [ metrics.categoryLevel1, metrics.categoryLevel2, metrics.categoryLevel3, ].filter(Boolean).join(' / '), categoryMatchLevel: getCategoryMatchLevel(candidate.category || [ metrics.categoryLevel1, metrics.categoryLevel2, metrics.categoryLevel3, ].filter(Boolean).join(' / '), categoryConstraint), }; return { ...enriched, detailUrl: buildMarketProductDetailUrl(enriched), }; }).sort((a, b) => ( Number(b.categoryMatchLevel || 0) - Number(a.categoryMatchLevel || 0) || Number(b.score || 0) - Number(a.score || 0) || parseMarketSalesVolume(b.salesVolume) - parseMarketSalesVolume(a.salesVolume) )); } function collectMarketReferenceCandidates(payload = {}) { const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload; const sources = [ data?.marketMatches, data?.market_matches, data?.marketProductLinks, data?.market_product_links, data?.matchedProducts, data?.matched_products, data?.products, data?.items, payload?.marketMatches, payload?.marketProductLinks, payload?.raw?.marketMatches, payload?.raw?.market_matches, payload?.raw?.data?.marketMatches, payload?.raw?.data?.market_matches, ]; const unique = new Map(); sources.forEach((rows) => { if (!Array.isArray(rows)) return; rows.forEach((row, index) => { const candidate = normalizeMarketReferenceCandidate(row, index); if (candidate && !unique.has(candidate.id)) unique.set(candidate.id, candidate); }); }); return Array.from(unique.values()); } async function searchMarketReferenceProductsWithOpenApi({ imageUrl, imageBase64, topK = 50, categoryConstraint = {}, }) { const config = await resolveSimilarProductsConfig(); if (!config.baseUrl || !config.token) { throw createExternalServiceError('市场以图搜图 API 未配置,请配置 SELECTION_SIMILAR_PRODUCTS_TOKEN'); } const payload = { top_k: normalizePositiveInteger(topK, 50, 100), include_raw: true, search_mode: 'appearance', score_threshold: normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5, category_level1: categoryConstraint.level1 || undefined, category_level2: categoryConstraint.level2 || undefined, category_level3: categoryConstraint.level3 || undefined, category_strict: Boolean(categoryConstraint.level2 || categoryConstraint.level3), }; if (imageUrl) payload.image_url = imageUrl; if (imageBase64) payload.image_base64 = imageBase64; if (!payload.image_url && !payload.image_base64) { const error = new Error('缺少用于市场以图搜图的图片'); error.status = 400; throw error; } try { const response = await axios.post(config.endpointUrl, payload, { timeout: config.timeoutMs, headers: getSimilarProductsRequestHeaders(config.token), maxBodyLength: 15 * 1024 * 1024, }); return response.data; } catch (error) { throw createSimilarProductsRequestError(error, '市场相似款查询失败'); } } async function searchSelectionFissionMarketReferences(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const categoryConstraint = normalizeCategoryConstraint(item); let imageUrl = toSearchableImageUrl(input.imageUrl || input.image_url || item.mainImageUrl); if (!imageUrl) { const profile = await refreshPlanItemMarketProfile(item.id, user); imageUrl = toSearchableImageUrl(profile?.mainImageUrl); } const batchNo = Math.max(1, Number(input.batchNo || input.batch_no || 1)); const imageBase64 = normalizeText(input.imageBase64 || input.image_base64, 10 * 1024 * 1024); const warnings = []; let raw; const marketSearchStartedAt = Date.now(); try { raw = await searchMarketReferenceProductsWithOpenApi({ imageUrl, imageBase64, topK: 100, categoryConstraint, }); logSelectionFissionPhase(planItemId, 'market_image_search', marketSearchStartedAt); } catch (openApiError) { warnings.push(`市场图搜直连不可用:${openApiError.message}`); logSelectionFissionPhase(planItemId, 'market_image_search_failed', marketSearchStartedAt); throw createExternalServiceError(`市场以图搜图失败:${openApiError.message}`, 502, 'MARKET_IMAGE_SEARCH_FAILED'); } const collectedCandidates = collectMarketReferenceCandidates(raw).map((candidate) => ({ ...candidate, channelName: candidate.channelName || categoryConstraint.channelName, })); const marketEnrichStartedAt = Date.now(); const enrichedCollected = await enrichMarketReferenceCandidates(collectedCandidates, categoryConstraint); let strictCandidates = filterStrictMarketReferenceCandidates(enrichedCollected, categoryConstraint); if (strictCandidates.length === 0 && enrichedCollected.length > 0) { // 严格类目过滤全军覆没时不直接失败:回退保留分数达标的候选。 // 历史/1688图源商品在淘宝向量库可能没有同品类命中,直接报错会阻断 // 待上架列表"继续处理"流程;类目放宽情况通过 warnings 明示。 const threshold = normalizeDecimal(process.env.SELECTION_MARKET_REFERENCE_SCORE_THRESHOLD) ?? 0.5; const relaxed = enrichedCollected.filter((candidate) => ( Number.isFinite(Number(candidate.score)) && Number(candidate.score) >= threshold )); if (relaxed.length > 0) { strictCandidates = relaxed; warnings.push(`严格类目过滤后无候选,已回退展示 ${relaxed.length} 个分数达标但类目未严格匹配的相似款`); } } const recommendLimit = ALIBABA_1688_MARKET_REFERENCE_RECOMMEND_LIMIT; const candidatePoolLimit = Math.min(100, recommendLimit * 2); const candidates = await enrichMarketReferenceCandidates( await supplementMarketReferenceCandidates( strictCandidates, categoryConstraint, candidatePoolLimit, imageUrl ), categoryConstraint ); logSelectionFissionPhase( planItemId, 'market_enrich_supplement', marketEnrichStartedAt, `collected=${collectedCandidates.length} strict=${strictCandidates.length} final=${candidates.length}` ); if (candidates.length === 0) { throw createExternalServiceError('市场以图搜图未返回可用的商品主图', 502, 'MARKET_IMAGE_SEARCH_EMPTY'); } const offset = candidates.length > recommendLimit ? ((batchNo - 1) * recommendLimit) % candidates.length : 0; let batchCandidates = candidates.slice(offset, offset + recommendLimit); if (batchCandidates.length < recommendLimit && candidates.length > batchCandidates.length) { batchCandidates = batchCandidates.concat(candidates.slice(0, recommendLimit - batchCandidates.length)); } const previousIds = new Set( (Array.isArray(item.marketReferenceBatch?.candidates) ? item.marketReferenceBatch.candidates : []) .map((candidate) => normalizeText(candidate.id, 240)) .filter(Boolean) ); const nextIds = batchCandidates.map((candidate) => normalizeText(candidate.id, 240)).filter(Boolean); const changed = nextIds.length !== previousIds.size || nextIds.some((id) => !previousIds.has(id)); const newCandidateCount = nextIds.filter((id) => !previousIds.has(id)).length; const batch = { batchNo, searchedImageUrl: imageUrl, searchedAt: new Date().toISOString(), totalCandidates: candidates.length, candidates: batchCandidates, changed, newCandidateCount, categoryConstraint, warnings, }; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET market_reference_batch_json = ?, updated_at = NOW() WHERE id = ?`, [safeJsonStringify(batch), item.id] ); logSelectionFissionPhase(planItemId, 'market_reference_total', marketSearchStartedAt, `candidates=${candidates.length}`); return batch; } function normalizeAlibaba1688Result(row = {}) { const itemId = normalizeText(row.itemId || row.item_id || row.offerId || row.offer_id, 120); return { itemId, title: normalizeText(row.title || row.subject || row.name, 1000), imageUrl: normalizeImageUrl(row.imageUrl || row.image_url || row.image), detailUrl: normalizeText(row.detailUrl || row.detail_url, 2000) || (itemId ? `https://detail.1688.com/offer/${itemId}.html` : ''), score: normalizeDecimal(row.score), currentPrice: row.currentPrice ?? row.current_price ?? row.price ?? null, skuId: normalizeText(row.skuId || row.sku_id, 120), skuTitle: normalizeText(row.skuTitle || row.sku_title, 500), quantityBegin: row.quantityBegin ?? row.quantity_begin ?? null, unit: normalizeText(row.unit, 80), company: normalizeText(row.company || row.companyName || row.supplierName, 500), soldOut: row.soldOut ?? row.sold_out ?? null, storeAmount: row.storeAmount ?? row.store_amount ?? null, industryName: normalizeText(row.industryName || row.industry_name, 300), promotionTags: Array.isArray(row.promotionTags) ? row.promotionTags : [], serviceInfos: Array.isArray(row.serviceInfos) ? row.serviceInfos : [], sellingPoints: Array.isArray(row.sellingPoints) ? row.sellingPoints : [], raw: row, }; } function getAlibaba1688ImageSearchFailureMessage(error) { const detail = error?.response?.data; return normalizeText( getAlibaba1688GatewayError(detail) || detail?.msgInfo || detail?.message || detail?.error || error?.message, 1000 ); } function isRetryableAlibaba1688ImageSearchFailure(error) { const message = getAlibaba1688ImageSearchFailureMessage(error); const diagnostic = `${normalizeText(error?.code, 80)} ${message}`; if (/(?:access\s*key|accesskey|\bak\b).*(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用)|(?:disabled|forbidden|invalid|expired|禁用|无效|过期|停用).*(?:access\s*key|accesskey|\bak\b)/i.test(diagnostic)) { return false; } const statuses = [ Number(error?.status || error?.statusCode || 0), Number(error?.response?.status || 0), ]; const code = normalizeText(error?.code, 80).toUpperCase(); return statuses.some((status) => ALIBABA_1688_RETRYABLE_STATUSES.has(status)) || ALIBABA_1688_RETRYABLE_CODES.has(code) || /(?:后端服务调用失败|服务繁忙|系统异常|暂时不可用|稍后再试|econnrefused|connection refused|socket hang up|network error|fetch failed|timed?\s*out)/i.test(message); } function getAlibaba1688RetryAttempts() { return normalizePositiveInteger( process.env.ALIBABA_1688_IMAGE_SEARCH_RETRY_ATTEMPTS, DEFAULT_ALIBABA_1688_RETRY_ATTEMPTS, MAX_ALIBABA_1688_RETRY_ATTEMPTS ); } function waitForAlibaba1688Retry(attempt) { return wait(Math.min(1000, 500 * (2 ** Math.max(0, attempt - 1)))); } async function searchSelectionFissionAlibaba1688Products(planId, planItemId, input = {}) { const item = await getPlanItem(planId, planItemId); const imageUrl = normalizeImageUrl(input.imageUrl || input.image_url || item.mainImageUrl); if (!imageUrl) throw Object.assign(new Error('当前市场 ID 缺少可用于1688寻款的参考图'), { status: 400 }); const config = await getAlibaba1688ImageSearchRuntimeConfig(); if (!config.endpointUrl || !config.ak) throw Object.assign(new Error('1688专用以图搜图尚未配置'), { status: 503 }); const payload = { imageUrl, pageSize: normalizePositiveInteger(input.pageSize || input.page_size, 10, 50), purchaseAmount: normalizePositiveInteger(input.purchaseAmount || input.purchase_amount, 1, 100000), sortType: normalizeText(input.sortType || input.sort_type, 40) || 'yx_desc', scoreLevel: normalizeText(input.scoreLevel || input.score_level, 20) || 'high', tags: normalizeText(input.tags, 300) || '4306497', icTags: normalizeText(input.icTags || input.ic_tags, 300), }; const body = JSON.stringify(payload); const endpoint = new URL(config.endpointUrl); const attempts = getAlibaba1688RetryAttempts(); for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await axios.post(config.endpointUrl, body, { timeout: config.timeoutMs, headers: buildAlibaba1688SignedHeaders({ method: 'POST', uri: `${endpoint.pathname}${endpoint.search}`, body, ak: config.ak }), transformRequest: [(data) => data], }); const envelope = response.data || {}; const businessError = getAlibaba1688GatewayError(envelope); if (businessError) { throw Object.assign(new Error(businessError), { status: 502, response: { status: Number(envelope.httpCode || envelope.http_code || 200), data: envelope }, }); } const model = envelope.model || envelope.data || envelope.result || envelope; const rows = Array.isArray(model?.data) ? model.data : Array.isArray(model?.items) ? model.items : Array.isArray(model) ? model : []; const products = rows.map(normalizeAlibaba1688Result).filter((row) => row.itemId || row.title); return { sourceImageUrl: imageUrl, count: Number(model?.count ?? envelope?.count ?? products.length), products, query: payload, }; } catch (error) { if (attempt < attempts && isRetryableAlibaba1688ImageSearchFailure(error)) { await waitForAlibaba1688Retry(attempt); continue; } const message = getAlibaba1688ImageSearchFailureMessage(error) || '1688专用接口请求失败'; const rawUpstreamStatus = Number(error?.response?.status || 0); const upstreamStatus = rawUpstreamStatus >= 400 ? rawUpstreamStatus : 0; const errorStatus = Number(error?.status || error?.statusCode || 0); const responseStatus = upstreamStatus === 401 || upstreamStatus === 403 ? 502 : upstreamStatus || errorStatus || 502; throw createExternalServiceError( `1688自动寻款失败${upstreamStatus ? `(上游 HTTP ${upstreamStatus})` : ''}:${message}`, responseStatus, 'ALIBABA_1688_IMAGE_SEARCH_FAILED' ); } } throw createExternalServiceError('1688自动寻款失败', 502, 'ALIBABA_1688_IMAGE_SEARCH_FAILED'); } async function confirmSelectionFissionMarketReferences(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const maxCount = isAlibaba1688Pending ? ALIBABA_1688_MARKET_REFERENCE_COUNT : 5; const selectedIds = Array.from(new Set( (Array.isArray(input.referenceIds) ? input.referenceIds : input.reference_ids || []) .map((value) => normalizeText(value, 240)) .filter(Boolean) )); if (selectedIds.length < 1 || selectedIds.length > maxCount) { const error = new Error(`请选择 1 至 ${maxCount} 个市场相似款作为主图生图参考`); error.status = 400; throw error; } const candidates = Array.isArray(item.marketReferenceBatch?.candidates) ? item.marketReferenceBatch.candidates : []; const candidateMap = new Map(candidates.map((candidate) => [normalizeText(candidate.id, 240), candidate])); const selected = selectedIds.map((id) => candidateMap.get(id)).filter(Boolean); if (selected.length !== selectedIds.length) { const error = new Error('所选参考款不在当前推荐批次中,请换一批后重新选择'); error.status = 400; throw error; } const categoryMismatches = selected.filter((reference) => ( Number.isFinite(Number(reference?.categoryMatchLevel)) && Number(reference.categoryMatchLevel) <= 0 )); if (categoryMismatches.length > 0) { const error = new Error('所选市场参考款与当前商品品类不一致,请换一批后重新选择'); error.status = 400; throw error; } const mainImageMode = normalizeAlibaba1688MainImageMode( input.mainImageMode ?? input.main_image_mode, 'market_composite' ); const mainImageCount = Math.min( normalizePositiveInteger( input.mainImageCount ?? input.main_image_count, selected.length, maxCount ), selected.length ); const previousIds = (Array.isArray(item.selectedMarketReferences) ? item.selectedMarketReferences : []) .map((reference) => normalizeText(reference?.id, 240)) .filter(Boolean); const selectionChanged = previousIds.length !== selectedIds.length || previousIds.some((id, index) => id !== selectedIds[index]); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET selected_market_references_json = ?, replaced_image_url = IF(?, NULL, replaced_image_url), main_image_generation_json = IF(?, NULL, main_image_generation_json), sku_scene_images_json = IF(?, NULL, sku_scene_images_json), sku_name_candidates_json = IF(?, NULL, sku_name_candidates_json), optimized_title_candidates_json = IF(?, NULL, optimized_title_candidates_json), title_keyword_strategy_json = IF(?, NULL, title_keyword_strategy_json), selected_product_title = IF(?, NULL, selected_product_title), workflow_step = IF(?, 3, workflow_step), workflow_status = IF(?, 'waiting', workflow_status), workflow_error_message = IF(?, NULL, workflow_error_message), updated_at = NOW() WHERE plan_id = ? AND source_item_id = ?`, [ safeJsonStringify(selected), selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, selectionChanged, item.planId, Number(item.sourceItemId || item.id), ] ); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET main_image_generation_json = ?, updated_at = NOW() WHERE plan_id = ? AND source_item_id = ?`, [ safeJsonStringify({ ...(selectionChanged ? {} : (item.mainImageGeneration || {})), mode: mainImageMode, requestedCount: mainImageCount, total: mainImageCount, planConfirmedAt: new Date().toISOString(), }), item.planId, Number(item.sourceItemId || item.id), ] ); await recordCurrentSelectionFissionRevision({ planId: item.planId, planItemId: item.id, revisionType: 'market_references', summary: `确认${selected.length}个市场相似款生图参考`, user, }); return { selectedMarketReferences: selected }; } async function searchSelectionFissionSimilarProducts(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const categoryConstraint = normalizeCategoryConstraint(item); let imageUrl = toSearchableImageUrl(input.imageUrl || input.image_url || item.replacedImageUrl || item.mainImageUrl); const imageBase64 = normalizeText(input.imageBase64 || input.image_base64, 10 * 1024 * 1024); const warnings = []; if (!imageUrl && !imageBase64) { const profile = await refreshPlanItemMarketProfile(item.id, user); if (profile?.mainImageUrl) { imageUrl = profile.mainImageUrl; } else if (profile?.warning) { warnings.push(profile.warning); } } const matchStartedAt = Date.now(); let result; try { result = await searchSimilarProductsWithOpenApi({ imageUrl, imageBase64, topK: input.topK || input.top_k || 100, scoreThreshold: input.scoreThreshold ?? input.score_threshold, includeRaw: input.includeRaw ?? input.include_raw, categoryConstraint, }); } catch (error) { if (!isSimilarProductsFallbackEligible(error)) throw error; warnings.push(`公司图搜直连不可用:${error.message}`); result = { spuCandidates: [], ownProductMatches: [], raw: null }; warnings.push('外部图搜服务暂时不可用,已按商品品类从公司商品库补足候选'); } const rawImageSearchCandidates = rankSpuCandidatesByCategory(result.spuCandidates || [], categoryConstraint); logSelectionFissionPhase(planItemId, 'company_image_search', matchStartedAt, `rawSpu=${rawImageSearchCandidates.length}`); const validateStartedAt = Date.now(); const spuValidation = await validateCompanySpuCandidates(rawImageSearchCandidates); logSelectionFissionPhase( planItemId, 'company_spu_validate', validateStartedAt, `valid=${spuValidation.validCandidates.length} invalid=${spuValidation.invalidCandidates.length} unverified=${spuValidation.unverifiedCandidates.length}` ); const imageSearchCandidates = spuValidation.validCandidates; warnings.push(...spuValidation.warnings); if (spuValidation.invalidCandidates.length > 0) { warnings.push(`已过滤 ${spuValidation.invalidCandidates.length} 个无法查询到公司 SKU 的无效编码`); } if (spuValidation.unverifiedCandidates.length > 0) { warnings.push(`已过滤 ${spuValidation.unverifiedCandidates.length} 个暂时无法完成公司 SPU 校验的编码`); } let rankedSpuCandidates = imageSearchCandidates; try { const supplementStartedAt = Date.now(); rankedSpuCandidates = await supplementCompanySpuCandidates( imageSearchCandidates, categoryConstraint, COMPANY_SPU_CANDIDATE_LIMIT ); logSelectionFissionPhase( planItemId, 'company_category_supplement', supplementStartedAt, `before=${imageSearchCandidates.length} after=${rankedSpuCandidates.length}` ); } catch (error) { warnings.push(`同品类公司商品补足失败:${error.message}`); } let enrichedSpuCandidates = rankedSpuCandidates; try { const enrichStartedAt = Date.now(); enrichedSpuCandidates = await enrichCompanySpuCandidates(rankedSpuCandidates); logSelectionFissionPhase( planItemId, 'company_metrics_enrich', enrichStartedAt, `count=${enrichedSpuCandidates.length}` ); } catch (error) { warnings.push(`公司经营指标补充失败:${error.message}`); } if (enrichedSpuCandidates.length === 0) { throw createExternalServiceError( '未匹配到可用的公司 SPU,请检查商品品类或配置公司图搜服务', 422, 'COMPANY_SPU_MATCH_EMPTY' ); } const payload = { ...result, spuCandidates: enrichedSpuCandidates, candidateCoverage: { requested: COMPANY_SPU_CANDIDATE_LIMIT, imageSearchReturnedCount: rawImageSearchCandidates.length, imageSearchCount: imageSearchCandidates.length, invalidSpuCount: spuValidation.invalidCandidates.length, unverifiedSpuCount: spuValidation.unverifiedCandidates.length, categorySupplementCount: Math.max(0, enrichedSpuCandidates.length - imageSearchCandidates.length), total: enrichedSpuCandidates.length, }, categoryConstraint, warnings, searchedImageUrl: imageUrl, searchedAt: new Date().toISOString(), }; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET similar_response_json = ?, main_image_url = COALESCE(NULLIF(?, ''), main_image_url), updated_at = NOW() WHERE id = ?`, [safeJsonStringify(payload), imageUrl || '', item.id] ); logSelectionFissionPhase(planItemId, 'company_match_total', matchStartedAt, `total=${enrichedSpuCandidates.length}`); return payload; } function mapCompanySkuRow(row = {}) { return { statDate: row.stat_date, skuCode: normalizeText(row.may_sku_code), productCode: normalizeText(row.product_code), spuCode: normalizeText(row.spu_code), skuName: normalizeText(row.sku_name || row.product_name, 255), productName: normalizeText(row.product_name, 255), categoryName: normalizeText(row.category_name, 255), supplierName: normalizeText(row.supplier_name, 255), cost: normalizeDecimal(row.product_cost), skuImageUrl: normalizeImageUrl(row.may_sku_image_url), saleStatus: normalizeText(row.may_sales_status, 120), minimumPrice: normalizeDecimal(row.may_min_limit_price), size: normalizeText(row.may_size, 255), colorName: normalizeText(row.may_color_name, 120), aftersaleRatio: normalizeDecimal(row.may_after_sales_ratio), promotionRatio: normalizeDecimal(row.may_promotion_fee_ratio || row.may_promotion_ratio), sourceSystem: COMPANY_SNAPSHOT_TABLE, raw: row, }; } function getJstSkuApiConfig() { return { baseUrl: stripTrailingSlash(getFirstEnvValue([ 'SELECTION_JST_SKU_API_BASE_URL', 'JST_SKU_API_BASE_URL', ], 'https://node.jiansuntuzhi.cn:7002')), timeoutMs: normalizePositiveInteger( process.env.SELECTION_JST_SKU_API_TIMEOUT_MS, 20000, 120000 ), }; } function mapJstCompanySkuRow(row = {}) { const dimensions = [row.l, row.w, row.h] .map((value) => normalizeText(value, 30)) .filter(Boolean) .join('×'); const stockWarehouses = (Array.isArray(row.stockWarehouses || row.stock_warehouses) ? (row.stockWarehouses || row.stock_warehouses) : []) .map((warehouse = {}) => ({ warehouseId: normalizeText(warehouse.warehouseId || warehouse.warehouse_id, 80), warehouseName: normalizeText(warehouse.warehouseName || warehouse.warehouse_name, 255), warehouseType: normalizeText(warehouse.warehouseType || warehouse.warehouse_type, 255), stock: normalizeNullableDecimal(warehouse.stock ?? warehouse.stockQty ?? warehouse.stock_qty), })) .filter((warehouse) => ( warehouse.warehouseId || warehouse.warehouseName || warehouse.warehouseType || warehouse.stock !== null )); return { statDate: row.modified || row.created || null, skuCode: normalizeText(row.sku_id || row.skuCode || row.sku_code, 160), productCode: normalizeText(row.i_id || row.productCode || row.product_code, 160), spuCode: normalizeText(row.i_id || row.spuCode || row.spu_code || row.spu, 160), skuName: normalizeText(row.name || row.skuName || row.sku_name, 255), productName: normalizeText(row.name || row.productName || row.product_name, 255), categoryName: normalizeText(row.category || row.categoryName || row.category_name, 255), material: normalizeText(row.material || row.core_material, 255), supplierName: normalizeText(row.supplier_name || row.supplierName, 255), cost: normalizeDecimal(row.cost_price ?? row.purchase_price ?? row.cost), skuImageUrl: normalizeImageUrl(row.pic || row.pic_big || row.skuImageUrl || row.sku_image_url), saleStatus: normalizeText(row.status || row.saleStatus || row.sale_status, 120), stock: normalizeNullableDecimal(row.stock ?? row.stockQty ?? row.stock_qty), stockWarehouses, minimumPrice: normalizeDecimal( row.nonOwnerLimitPrice ?? row.non_owner_limit_price ?? row.minimumPrice ?? row.minimum_price ), size: normalizeText(row.size || dimensions, 255), colorName: normalizeText( row.properties_value || row.colorName || row.color_name || row.color, 120 ), aftersaleRatio: null, promotionRatio: null, sourceSystem: 'getJstSkusBySpu', limitPriceTable: normalizeText(row.limitPriceTable || row.limit_price_table, 120), raw: row, }; } async function fetchCompanySkusFromJstApi(normalizedSpu, limit, options = {}) { const includeInactive = options.includeInactive === true; const config = getJstSkuApiConfig(); const response = await axios.get(`${config.baseUrl}/JS/oa/getJstSkusBySpu`, { timeout: config.timeoutMs, params: { spu: normalizedSpu }, headers: { Accept: 'application/json' }, maxContentLength: 20 * 1024 * 1024, }); const payload = response.data || {}; if (Number(payload.code) !== 200000 || !Array.isArray(payload.result)) { throw createExternalServiceError( normalizeText(payload.result?.errMsg || payload.msg, 500) || '公司SKU接口返回格式不正确', 502, 'JST_SKU_API_INVALID_RESPONSE' ); } const rowsBySku = new Map(); for (const raw of payload.result) { const row = mapJstCompanySkuRow(raw); if (!row.skuCode || (!includeInactive && row.saleStatus && row.saleStatus !== '销售')) continue; rowsBySku.set(row.skuCode, row); } // Map 保留接口首次返回每个条码时的插入顺序,避免按条码重排后把停产/退市置于销售之前。 return Array.from(rowsBySku.values()).slice(0, limit); } async function fetchCompanySkusFromSnapshot(normalizedSpu, limit, options = {}) { const likePrefix = `${normalizedSpu}-%`; const underscorePrefix = `${normalizedSpu}_%`; const rows = await planningDatabase.query( `WITH latest_snapshot AS ( SELECT MAX(stat_date) AS stat_date FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} ), matched_rows AS ( SELECT p.*, ROW_NUMBER() OVER ( PARTITION BY p.may_sku_code ORDER BY p.generated_at DESC, p.id DESC ) AS row_no FROM ${escapeIdentifier(COMPANY_SNAPSHOT_TABLE)} p INNER JOIN latest_snapshot latest ON latest.stat_date = p.stat_date WHERE NULLIF(TRIM(p.may_sku_code), '') IS NOT NULL AND ( UPPER(TRIM(COALESCE(p.product_code, ''))) = ? OR UPPER(TRIM(COALESCE(p.spu_code, ''))) = ? OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) = ? OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) LIKE ? OR UPPER(TRIM(COALESCE(p.may_sku_code, ''))) LIKE ? ) ) SELECT * FROM matched_rows WHERE row_no = 1 ORDER BY may_sku_code ASC LIMIT ?`, [normalizedSpu, normalizedSpu, normalizedSpu, likePrefix, underscorePrefix, limit] ); const mappedRows = rows.map(mapCompanySkuRow); return options.includeInactive === true ? mappedRows : mappedRows.filter((row) => !row.saleStatus || row.saleStatus === '销售'); } async function getCompanySkusBySpu(spuCode, options = {}) { const normalizedSpu = normalizeText(spuCode, 160).toUpperCase(); if (!normalizedSpu) { const error = new Error('缺少公司SPU'); error.status = 400; throw error; } const limit = normalizePositiveInteger(options.limit, MAX_SKU_QUERY_LIMIT, MAX_SKU_QUERY_LIMIT); const includeInactive = options.includeInactive === true; try { const rows = await fetchCompanySkusFromJstApi(normalizedSpu, limit, { includeInactive }); return { rows, source: 'getJstSkusBySpu', warnings: [], }; } catch (error) { const rows = await fetchCompanySkusFromSnapshot(normalizedSpu, limit, { includeInactive }); return { rows, source: COMPANY_SNAPSHOT_TABLE, warnings: [`公司SKU实时接口不可用,已读取本地快照:${error.message || '未知错误'}`], }; } } function isSelectionFissionSpuCurrent(item = {}, expectedSpuCode = '') { const currentSpu = normalizeText(item.selectedSpuCode, 160).toUpperCase(); const expectedSpu = normalizeText(expectedSpuCode, 160).toUpperCase(); return Boolean(currentSpu && expectedSpu && currentSpu === expectedSpu); } async function loadAndStoreItemSkus(planId, planItemId, spuCode) { const item = await getPlanItem(planId, planItemId); if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { const snapshot = item.skuSnapshot?.rows?.length ? item.skuSnapshot : buildAlibaba1688SkuSnapshot(item); if (!item.skuSnapshot?.rows?.length) { await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_snapshot_json = ?, white_background_assets_json = ?, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(snapshot), safeJsonStringify(buildSkuSelectionAssetsFromSnapshot(snapshot)), item.id, ] ); } return snapshot; } const targetSpu = normalizeText(spuCode || item.selectedSpuCode, 160).toUpperCase(); if (!isSelectionFissionSpuCurrent(item, targetSpu)) { throw createExternalServiceError( '当前SPU已经变更,请刷新后重新读取公司SKU', 409, 'SELECTION_SPU_CHANGED' ); } const skuResult = await getCompanySkusBySpu(targetSpu); const snapshot = mergeSelectedSkuRowsWithLegacySnapshot(item, { spuCode: targetSpu, total: skuResult.rows.length, rows: skuResult.rows, fetchedAt: new Date().toISOString(), source: skuResult.source, warnings: skuResult.warnings, }); const updateResult = await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_snapshot_json = ?, updated_at = NOW() WHERE id = ? AND plan_id = ? AND UPPER(TRIM(COALESCE(selected_spu_code, ''))) = ?`, [safeJsonStringify(snapshot), item.id, item.planId, targetSpu] ); if (Number(updateResult?.affectedRows || 0) !== 1) { throw createExternalServiceError( '读取公司SKU期间SPU已被切换,本次旧结果未写入,请重新读取', 409, 'SELECTION_SPU_CHANGED' ); } return snapshot; } function normalizeSpuSelections(input = {}) { const rawSelections = Array.isArray(input.selections) ? input.selections : Array.isArray(input.spus) ? input.spus : [input]; const byCode = new Map(); for (const raw of rawSelections) { const candidate = raw?.candidate && typeof raw.candidate === 'object' ? raw.candidate : raw; const spuCode = normalizeText( raw?.spuCode || raw?.productCode || raw?.spu_code || raw?.product_code || candidate?.spuCode || candidate?.productCode, 160 ).toUpperCase(); if (!spuCode) continue; byCode.set(spuCode, { spuCode, spuName: normalizeText( raw?.spuName || raw?.productName || raw?.spu_name || candidate?.spuName || candidate?.skuName, 255 ), candidate, }); } return Array.from(byCode.values()).slice(0, MAX_SPUS_PER_MARKET_ITEM); } async function confirmSelectionFissionSpu(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); let selections = normalizeSpuSelections(input); if (selections.length === 0) { const error = new Error('请至少选择一个公司SPU'); error.status = 400; throw error; } const validation = await validateCompanySpuCandidates( selections.map((selection) => ({ ...selection.candidate, spuCode: selection.spuCode })) ); if (validation.unverifiedCandidates.length > 0) { throw createExternalServiceError( `暂时无法校验公司 SPU:${validation.unverifiedCandidates.map((row) => row.spuCode).join('、')},请稍后重试`, 503, 'COMPANY_SPU_VERIFICATION_UNAVAILABLE' ); } if (validation.invalidCandidates.length > 0) { throw createExternalServiceError( `以下编码不是有效的公司 SPU:${validation.invalidCandidates.map((row) => row.spuCode).join('、')}`, 422, 'INVALID_COMPANY_SPU' ); } const verifiedByCode = new Map(validation.validCandidates.map((candidate) => [candidate.spuCode, candidate])); selections = selections.map((selection) => ({ ...selection, candidate: { ...selection.candidate, ...verifiedByCode.get(selection.spuCode), }, })); const sourceItemId = Number(item.sourceItemId || item.id); const plan = await getSelectionFissionPlanDetail(planId); const sourceItem = plan.items.find((candidate) => Number(candidate.id) === sourceItemId); if (!sourceItem) { const error = new Error('市场ID源明细不存在'); error.status = 404; throw error; } const schemeCode = plan.schemeCode; await planningDatabase.withTransaction(async (connection) => { const [existingRows] = await connection.execute( `SELECT * FROM ${escapeIdentifier(ITEM_TABLE)} WHERE plan_id = ? AND source_item_id = ? ORDER BY variant_no ASC, id ASC FOR UPDATE`, [item.planId, sourceItemId] ); if (existingRows.some((row) => ( normalizeText(row.listing_status, 20) !== 'draft' || normalizeText(row.listed_item_id, 160) ))) { const error = new Error('该市场ID已有自动上架中的SPU任务,不能重新选择'); error.status = 409; throw error; } if (existingRows.some((row) => normalizeText(row.workflow_status, 20) === 'running')) { const error = new Error('该市场ID仍有SPU节点正在处理,请完成后再重新选择'); error.status = 409; throw error; } await deleteSelectionFissionTaskArtifacts( connection, item.planId, existingRows.map((row) => row.id) ); for (let index = 0; index < selections.length; index += 1) { const selection = selections[index]; const variantNo = index + 1; let targetId = existingRows.find((row) => Number(row.variant_no || 1) === variantNo)?.id; if (!targetId) { const [insertResult] = await connection.execute( `INSERT INTO ${escapeIdentifier(ITEM_TABLE)} ( plan_id, source_item_id, slot_no, variant_no, task_code, market_item_id, channel_name, title, shop_name, category_level1, category_level2, category_level3, main_image_url, market_detail_url, price_band, price_band_list_json, similar_response_json, market_reference_batch_json, selected_market_references_json, market_profile_json, metadata_json, listing_status, workflow_status, workflow_step, created_at, updated_at ) SELECT plan_id, ?, slot_no, ?, ?, market_item_id, channel_name, title, shop_name, category_level1, category_level2, category_level3, main_image_url, market_detail_url, price_band, price_band_list_json, similar_response_json, market_reference_batch_json, selected_market_references_json, market_profile_json, metadata_json, 'draft', 'waiting', 2, NOW(), NOW() FROM ${escapeIdentifier(ITEM_TABLE)} WHERE id = ?`, [ sourceItemId, variantNo, `${schemeCode}-S${String(sourceItem.slotNo).padStart(2, '0')}-${String(variantNo).padStart(2, '0')}`, sourceItemId, ] ); targetId = insertResult.insertId; } await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET variant_no = ?, task_code = ?, selected_spu_code = ?, selected_spu_name = ?, selected_spu_payload_json = ?, white_background_assets_json = NULL, selected_main_sku_code = NULL, selected_main_sku_name = NULL, selected_main_sku_image_url = NULL, replaced_image_url = NULL, main_image_generation_json = NULL, sku_scene_images_json = NULL, sku_name_candidates_json = NULL, title_keyword_strategy_json = NULL, optimized_title_candidates_json = NULL, selected_product_title = NULL, title_ai_model = NULL, title_ai_generated_at = NULL, sku_snapshot_json = NULL, video_assets_json = NULL, freight_profile_json = NULL, workflow_step = 2, workflow_status = 'waiting', workflow_error_message = NULL, listing_status = 'draft', listed_item_id = NULL, claim_token_hash = NULL, claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, claim_count = 0, listing_error_code = NULL, listing_error_message = NULL, listing_completed_at = NULL, pricing_table_saved_at = NULL, pricing_table_saved_by = NULL, pricing_table_version = 0, product_code_status = 'not_requested', product_code_style_code = NULL, product_code_request_hash = NULL, product_code_config_json = NULL, product_code_request_json = NULL, product_code_response_json = NULL, product_code_error_message = NULL, product_code_requested_at = NULL, product_code_completed_at = NULL, updated_at = NOW() WHERE id = ?`, [ variantNo, `${schemeCode}-S${String(sourceItem.slotNo).padStart(2, '0')}-${String(variantNo).padStart(2, '0')}`, selection.spuCode, selection.spuName || null, safeJsonStringify(selection.candidate), targetId, ] ); } const extraIds = existingRows .filter((row) => Number(row.variant_no || 1) > selections.length) .map((row) => row.id); if (extraIds.length > 0) { await connection.execute( `DELETE FROM ${escapeIdentifier(ITEM_TABLE)} WHERE id IN (${extraIds.map(() => '?').join(', ')})`, extraIds ); } }); await updatePlanWorkflowState(planId, { status: 'waiting', step: 2, progress: 20, errorMessage: '', }); const detail = await getSelectionFissionPlanDetail(planId); return { item: detail.items.find((candidate) => candidate.id === item.id) || null, tasks: detail.listingTasks.filter((task) => task.sourceItemId === sourceItemId), plan: detail, }; } async function getSelectionFissionItemSkus(planId, planItemId) { const item = await getPlanItem(planId, planItemId); if (!item.selectedSpuCode) { const error = new Error('请先确认公司SPU'); error.status = 400; throw error; } return loadAndStoreItemSkus(planId, planItemId, item.selectedSpuCode); } function getMaterialAssetsConfig() { const baseUrl = stripTrailingSlash(getFirstEnvValue([ 'SELECTION_MATERIAL_ASSETS_BASE_URL', 'MATERIAL_ASSETS_BASE_URL', ], 'https://sc.jiansun.vip')); const apiKey = getFirstEnvValue([ 'SELECTION_MATERIAL_ASSETS_API_KEY', 'MATERIAL_ASSETS_API_KEY', 'SELECTION_VIDEO_ASSETS_API_KEY', 'VIDEO_ASSETS_API_KEY', ]); return { baseUrl, apiKey, timeoutMs: normalizePositiveInteger( process.env.SELECTION_MATERIAL_ASSETS_TIMEOUT_MS, DEFAULT_EXTERNAL_TIMEOUT_MS ), }; } async function fetchWhiteBackgroundSkuAssets(spuCode) { const normalizedSpu = normalizeText(spuCode, 160).toUpperCase(); if (!normalizedSpu) { throw createExternalServiceError('缺少SPU,无法查询公司白底图', 400, 'SPU_REQUIRED'); } const config = getMaterialAssetsConfig(); if (!config.apiKey) { throw createExternalServiceError( '公司白底图素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' ); } const response = await axios.get( `${config.baseUrl}/api/external/standard-model-white-background`, { timeout: config.timeoutMs, params: { resourceType: 'white-background', spu: normalizedSpu, }, headers: { 'X-API-Key': config.apiKey }, maxContentLength: 20 * 1024 * 1024, } ); const payload = response.data?.data || response.data || {}; const skuList = Array.isArray(payload.skuList) ? payload.skuList.map((row) => ({ skuCode: normalizeText(row.skuName || row.skuCode || row.sku, 160), skuId: normalizeText(row.skuId || row.id, 160), skuName: normalizeText(row.displayName || row.name || row.skuName, 255), hasWhiteBackground: Boolean(row.hasWhiteBackground), whiteBackgroundCount: Number(row.whiteBackgroundCount || 0), previewImage: row.previewImage && typeof row.previewImage === 'object' ? { previewUrl: normalizeImageUrl(row.previewImage.previewUrl), downloadUrl: normalizeText(row.previewImage.downloadUrl, 2000), fileName: normalizeText(row.previewImage.fileName, 500), fileSize: row.previewImage.fileSize ?? null, } : null, })) : []; return { resourceType: 'white-background', spuCode: normalizedSpu, materialId: normalizeText(payload.materialId, 160), shopName: normalizeText(payload.shopName || payload.categoryName, 255), total: skuList.length, rows: skuList, fetchedAt: new Date().toISOString(), raw: response.data, }; } function normalizeWhiteBackgroundSkuAssetDetails(payload = {}, requestedSkuCode = '') { const data = payload?.data || payload || {}; const normalizedSkuCode = normalizeText( data.sku || data.skuCode || requestedSkuCode, 160 ).toUpperCase(); const groups = [ ['fixed', Array.isArray(data.whiteBackground?.fixed) ? data.whiteBackground.fixed : []], ['other', Array.isArray(data.whiteBackground?.other) ? data.whiteBackground.other : []], ]; const seenUrls = new Set(); const rows = []; for (const [group, images] of groups) { images.forEach((image, index) => { const previewUrl = normalizeImageUrl(image.previewUrl || image.downloadUrl); if (!previewUrl || seenUrls.has(previewUrl)) return; seenUrls.add(previewUrl); const imageKey = normalizeText(image.key || `${group}-${index + 1}`, 160); rows.push({ assetKey: `${group}:${imageKey}`, group, key: imageKey, label: normalizeText(image.label || image.fileName || `素材 ${rows.length + 1}`, 255), previewUrl, downloadUrl: normalizeText(image.downloadUrl || previewUrl, 2000), fileName: normalizeText(image.fileName, 500), fileSize: image.fileSize ?? null, }); }); } return { resourceType: 'white-background', skuCode: normalizedSkuCode, materialId: normalizeText(data.materialId, 160), productModel: normalizeText(data.productModel, 160), shopName: normalizeText(data.shopName || data.categoryName, 255), total: rows.length, rows, fetchedAt: new Date().toISOString(), }; } async function fetchWhiteBackgroundSkuAssetDetails(skuCode) { const normalizedSkuCode = normalizeText(skuCode, 160).toUpperCase(); if (!normalizedSkuCode) { throw createExternalServiceError('缺少SKU,无法查询素材库', 400, 'SKU_REQUIRED'); } const config = getMaterialAssetsConfig(); if (!config.apiKey) { throw createExternalServiceError( '公司白底图素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' ); } const response = await axios.get( `${config.baseUrl}/api/external/standard-model-white-background`, { timeout: config.timeoutMs, params: { resourceType: 'white-background', sku: normalizedSkuCode, }, headers: { 'X-API-Key': config.apiKey }, maxContentLength: 20 * 1024 * 1024, } ); return normalizeWhiteBackgroundSkuAssetDetails(response.data, normalizedSkuCode); } async function fetchTaskDetailSkuAssets(skuCode) { const normalizedSkuCode = normalizeText(skuCode, 160).toUpperCase(); if (!normalizedSkuCode) { throw createExternalServiceError('缺少SKU,无法查询素材库SKU图', 400, 'SKU_REQUIRED'); } const config = getMaterialAssetsConfig(); if (!config.apiKey) { throw createExternalServiceError( '公司素材库接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' ); } const response = await axios.get( `${config.baseUrl}/api/external/standard-model-white-background`, { timeout: config.timeoutMs, params: { resourceType: 'task-detail', sku: normalizedSkuCode }, headers: { 'X-API-Key': config.apiKey }, maxContentLength: 20 * 1024 * 1024, } ); const data = response.data?.data || response.data || {}; const rows = []; const seenUrls = new Set(); (Array.isArray(data.candidates) ? data.candidates : []).forEach((candidate, candidateIndex) => { const materialId = normalizeText(candidate.materialId || `candidate-${candidateIndex + 1}`, 160); (Array.isArray(candidate.downloadUrls?.sku) ? candidate.downloadUrls.sku : []) .forEach((imageUrl, imageIndex) => { const previewUrl = normalizeImageUrl(imageUrl); if (!previewUrl || seenUrls.has(previewUrl)) return; seenUrls.add(previewUrl); rows.push({ assetKey: `task-detail:${materialId}:${imageIndex + 1}`, group: 'material-library', key: `${materialId}:${imageIndex + 1}`, label: `${normalizeText(candidate.sceneName, 120) || '素材库'} SKU图 ${imageIndex + 1}`, previewUrl, downloadUrl: previewUrl, fileName: '', fileSize: null, materialId, taskNo: normalizeText(candidate.taskNo, 160), shopName: normalizeText(candidate.shopName || candidate.categoryName, 255), sceneName: normalizeText(candidate.sceneName, 120), uploadedAt: candidate.uploadedAt || null, }); }); }); return { resourceType: 'task-detail', skuCode: normalizedSkuCode, total: rows.length, rows, fetchedAt: new Date().toISOString(), }; } async function fetchTaskDetailSpuAssets(spuCode) { const normalizedSpuCode = normalizeText(spuCode, 160).toUpperCase(); if (!normalizedSpuCode) { throw createExternalServiceError('缺少SPU,无法查询公司详情套图', 400, 'SPU_REQUIRED'); } const config = getMaterialAssetsConfig(); if (!config.apiKey) { throw createExternalServiceError( '公司详情素材接口未配置 SELECTION_MATERIAL_ASSETS_API_KEY' ); } const response = await axios.get( `${config.baseUrl}/api/external/standard-model-white-background`, { timeout: config.timeoutMs, params: { resourceType: 'task-detail', spu: normalizedSpuCode }, headers: { 'X-API-Key': config.apiKey }, maxContentLength: 20 * 1024 * 1024, } ); return response.data?.data || response.data || {}; } async function buildDeliveryMaterials(item) { const spuCode = normalizeText(item.selectedSpuCode, 160).toUpperCase(); if (!spuCode) return []; if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { const { mainImages, detailImages } = getAlibaba1688SourceImages(item); const skuImages = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) .map((row) => normalizeImageUrl(row.skuImageUrl)) .filter(Boolean); return [{ spu: spuCode, source: 'alibaba-1688-product', whiteBackground: null, taskDetail: { resourceType: 'task-detail', candidateCount: detailImages.length > 0 ? 1 : 0, candidates: detailImages.length > 0 ? [{ materialId: `1688:${normalizeText(item.marketItemId, 160) || item.id}`, sceneName: '1688商品原始详情', uploadedAt: item.updatedAt || item.createdAt || null, downloadUrls: { main: mainImages, sku: skuImages, detail: detailImages, }, }] : [], }, errors: detailImages.length > 0 ? [] : ['1688商品缺少原始详情图'], }]; } const [whiteBackgroundResult, taskDetailResult] = await Promise.allSettled([ fetchWhiteBackgroundSkuAssets(spuCode), fetchTaskDetailSpuAssets(spuCode), ]); const errors = []; if (whiteBackgroundResult.status === 'rejected') { errors.push(`白底图:${whiteBackgroundResult.reason?.message || '查询失败'}`); } if (taskDetailResult.status === 'rejected') { errors.push(`公司详情:${taskDetailResult.reason?.message || '查询失败'}`); } const whiteBackgroundData = whiteBackgroundResult.status === 'fulfilled' ? whiteBackgroundResult.value : null; const taskDetail = taskDetailResult.status === 'fulfilled' ? taskDetailResult.value : null; return [{ spu: spuCode, source: 'company-material-service', whiteBackground: whiteBackgroundData ? { resourceType: 'white-background', spu: spuCode, materialId: whiteBackgroundData.materialId, skuList: (whiteBackgroundData.rows || []).map((row) => ({ skuName: row.skuCode, skuId: row.skuId, hasWhiteBackground: row.hasWhiteBackground, whiteBackgroundCount: row.whiteBackgroundCount, previewImage: row.previewImage, })), } : null, taskDetail, errors, }]; } function buildSkuSelectionAssetsFromSnapshot(snapshot, supplementaryAssets = null) { const supplementaryBySku = new Map( (supplementaryAssets?.rows || []).map((row) => [String(row.skuCode || '').toUpperCase(), row]) ); const rows = (snapshot?.rows || []).map((row) => { const supplementary = supplementaryBySku.get(String(row.skuCode || '').toUpperCase()); const originalPreviewUrl = normalizeImageUrl(row.skuImageUrl); const materialPreviewUrl = normalizeImageUrl(supplementary?.previewImage?.previewUrl); const previewUrl = originalPreviewUrl || materialPreviewUrl; return { skuCode: row.skuCode, skuId: row.skuCode, skuName: row.skuName || row.skuCode, hasWhiteBackground: Boolean(previewUrl), whiteBackgroundCount: previewUrl ? 1 : 0, previewImage: previewUrl ? { previewUrl, downloadUrl: supplementary?.previewImage?.downloadUrl || previewUrl, fileName: supplementary?.previewImage?.fileName || '', fileSize: supplementary?.previewImage?.fileSize ?? null, } : null, originalPreviewImage: originalPreviewUrl ? { previewUrl: originalPreviewUrl, downloadUrl: originalPreviewUrl, fileName: '', fileSize: null, } : null, materialLibraryPreviewImage: materialPreviewUrl ? { previewUrl: materialPreviewUrl, downloadUrl: supplementary?.previewImage?.downloadUrl || materialPreviewUrl, fileName: supplementary?.previewImage?.fileName || '', fileSize: supplementary?.previewImage?.fileSize ?? null, } : null, selectedImageSource: originalPreviewUrl ? 'company-sku' : materialPreviewUrl ? 'material-library-auto' : '', selectedAssetKey: originalPreviewUrl ? 'original' : materialPreviewUrl ? 'material-preview' : '', cost: row.cost ?? null, minimumPrice: row.minimumPrice ?? null, sourceSystem: row.sourceSystem || snapshot?.source || 'getJstSkusBySpu', }; }); return { resourceType: 'company-sku-image', spuCode: snapshot?.spuCode || supplementaryAssets?.spuCode || '', materialId: supplementaryAssets?.materialId || '', shopName: supplementaryAssets?.shopName || '', total: rows.length, rows, fetchedAt: new Date().toISOString(), source: snapshot?.source || 'getJstSkusBySpu', warnings: snapshot?.warnings || [], }; } function getSkuSelectionImage(item = {}, skuCode = '') { const normalizedSkuCode = normalizeSkuCode(skuCode); const assetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []).find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []).find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); return normalizeImageUrl( assetRow?.previewImage?.previewUrl || snapshotRow?.skuImageUrl ); } // Main-image replacement needs the cleanest available product source. Keep // normal SKU selection behavior unchanged, but prefer a material-library // white-background asset when the saved selection is still the raw 1688 image. function getMainGenerationSourceImage(task = {}) { const skuCode = normalizeSkuCode(task.selectedMainSkuCode); const assetRow = (Array.isArray(task.whiteBackgroundAssets?.rows) ? task.whiteBackgroundAssets.rows : []).find((row) => normalizeSkuCode(row.skuCode) === skuCode); const selectedUrl = normalizeImageUrl(assetRow?.previewImage?.previewUrl); const originalUrl = normalizeImageUrl( assetRow?.originalPreviewImage?.previewUrl || (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) .find((row) => normalizeSkuCode(row.skuCode) === skuCode)?.skuImageUrl ); const materialUrl = normalizeImageUrl(assetRow?.materialLibraryPreviewImage?.previewUrl); const taskUrl = normalizeImageUrl(task.selectedMainSkuImageUrl); // Preserve an explicit material-library choice. If the current choice is // the original 1688 image, use a cleaner material-library preview instead. if (selectedUrl && assetRow?.selectedAssetKey && assetRow.selectedAssetKey !== 'original') { return selectedUrl; } if (materialUrl && (!taskUrl || taskUrl === originalUrl || selectedUrl === originalUrl)) { return materialUrl; } return selectedUrl || taskUrl || originalUrl; } function normalizeSkuCode(value) { return normalizeText(value, 160).trim().toUpperCase(); } function getWorkflowSelectedSkuCodes(task = {}) { const selectedCodes = Array.isArray(task.skuSceneImages?.selectedSkuCodes) ? task.skuSceneImages.selectedSkuCodes .map((value) => normalizeText(value, 160)) .filter(Boolean) : []; if (selectedCodes.length > 0) { return Array.from(new Set(selectedCodes)); } return Array.from(new Set( (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) .map((row) => normalizeText(row.skuCode, 160)) .filter(Boolean) )); } function getMaxMainImageCount(task = {}) { return getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE ? ALIBABA_1688_MARKET_REFERENCE_COUNT : 5; } function normalizeAlibaba1688MainImageMode(value, fallback = 'market_composite') { const normalized = normalizeText(value, 32).toLowerCase(); return ALIBABA_1688_MAIN_IMAGE_MODES.has(normalized) ? normalized : fallback; } function requiresMainImageQualityReview(mode) { return normalizeAlibaba1688MainImageMode(mode) !== 'source_original'; } function normalizeSkuImageMode(value, fallback = 'source_original') { const normalized = normalizeText(value, 32).toLowerCase(); return SKU_IMAGE_MODES.has(normalized) ? normalized : fallback; } function requiresSkuImageQualityReview(mode) { return normalizeSkuImageMode(mode) !== 'source_original'; } function getConfiguredMainImageCount(task = {}, fallback = 0) { const maxCount = getMaxMainImageCount(task); const requestedCount = normalizePositiveInteger( task.mainImageGeneration?.requestedCount, 0, maxCount ); if (requestedCount > 0) return requestedCount; const selectedCount = Array.isArray(task.selectedMarketReferences) ? task.selectedMarketReferences.filter((reference) => normalizeImageUrl(reference?.imageUrl)).length : 0; if (selectedCount > 0) return Math.min(selectedCount, maxCount); const generatedTotal = normalizePositiveInteger(task.mainImageGeneration?.total, 0, maxCount); if (generatedTotal > 0) return generatedTotal; const generatedRows = Array.isArray(task.mainImageGeneration?.rows) ? task.mainImageGeneration.rows.length : 0; if (generatedRows > 0) return Math.min(generatedRows, maxCount); return Math.min(Math.max(Number(fallback) || 0, 0), maxCount); } function getWorkflowSelectedSkuRows(task = {}, skuRowsOverride = null) { const skuRows = Array.isArray(skuRowsOverride) ? skuRowsOverride : (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []); const selectedCodes = new Set(getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean)); if (selectedCodes.size === 0) return []; return skuRows.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); } function filterWorkflowRowsBySelectedSkus(task = {}, rows = []) { const normalizedRows = Array.isArray(rows) ? rows : []; const selectedCodes = new Set( getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean) ); if (selectedCodes.size === 0) return normalizedRows; const rowsWithSkuCode = normalizedRows.filter((row) => normalizeSkuCode(row.skuCode)); if (rowsWithSkuCode.length === 0) { // Historical pricing snapshots predate per-row skuCode. Preserve their // positional rows while all newly captured 1688 data remains code-scoped. return normalizedRows; } return rowsWithSkuCode.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); } function mergeSelectedSkuRowsWithLegacySnapshot(task = {}, nextSnapshot = {}) { const nextRows = Array.isArray(nextSnapshot.rows) ? nextSnapshot.rows : []; const previousRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; if (previousRows.length === 0) return nextSnapshot; const selectedCodes = Array.from(new Set( getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode).filter(Boolean) )); if (selectedCodes.length === 0) return nextSnapshot; const currentCodes = new Set(nextRows.map((row) => normalizeSkuCode(row.skuCode)).filter(Boolean)); const previousByCode = new Map(); for (const row of previousRows) { const code = normalizeSkuCode(row.skuCode); if (code && !previousByCode.has(code)) previousByCode.set(code, row); } const legacyRows = selectedCodes .filter((code) => !currentCodes.has(code) && previousByCode.has(code)) .map((code) => previousByCode.get(code)); if (legacyRows.length === 0) return nextSnapshot; const source = normalizeText(nextSnapshot.source, 120) || 'getJstSkusBySpu'; const warnings = Array.isArray(nextSnapshot.warnings) ? nextSnapshot.warnings.filter(Boolean) : []; warnings.push(`${legacyRows.length} selected SKU row(s) were retained from the previous snapshot because the upstream SKU list changed.`); return { ...nextSnapshot, rows: [...nextRows, ...legacyRows], total: nextRows.length + legacyRows.length, source: `${source}+legacy-selected`, warnings, }; } function splitIntoBatches(rows = [], batchSize = SKU_IMAGE_GENERATION_BATCH_SIZE) { const normalizedBatchSize = normalizePositiveInteger(batchSize, SKU_IMAGE_GENERATION_BATCH_SIZE); const batches = []; for (let index = 0; index < rows.length; index += normalizedBatchSize) { batches.push(rows.slice(index, index + normalizedBatchSize)); } return batches; } function groupSkuSceneRowsBySourceImage(rows = []) { return (Array.isArray(rows) ? rows : []).map((row) => ({ sourceImageKey: `sku:${normalizeSkuCode(row.skuCode)}`, sourceImageUrl: normalizeImageUrl(row.originalImageUrl), rows: [row], })); } function summarizeSkuSceneImageRequests(rows = []) { const groups = groupSkuSceneRowsBySourceImage(rows); const completedCount = groups.filter((group) => group.rows.every((row) => row.generatedImageUrl)).length; const failedCount = groups.filter((group) => ( !group.rows.some((row) => row.generatedImageUrl) && group.rows.some((row) => row.status === 'failed') )).length; const processedCount = completedCount + failedCount; return { requestTotal: groups.length, requestCompletedCount: completedCount, requestFailedCount: failedCount, requestProcessedCount: processedCount, requestProgress: groups.length > 0 ? Math.round((processedCount / groups.length) * 100) : 0, }; } function prepareSkuSceneImageRows(task = {}, selectedSkuRows = [], retrySkuCodes = []) { const existingBySku = new Map( (Array.isArray(task.skuSceneImages?.rows) ? task.skuSceneImages.rows : []) .filter((row) => row.skuCode) .map((row) => [normalizeSkuCode(row.skuCode), row]) ); const retrySet = new Set(retrySkuCodes.map(normalizeSkuCode).filter(Boolean)); const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); const marketReferenceImageUrl = normalizeImageUrl( task.skuSceneImages?.marketReference?.imageUrl || task.skuSceneImages?.marketReferenceImageUrl ); const preparedRows = selectedSkuRows.map((sku) => { const skuCode = normalizeText(sku.skuCode, 160); const normalizedSkuCode = normalizeSkuCode(skuCode); const skuName = normalizeText(sku.skuName || sku.productName || skuCode, 255); const existing = existingBySku.get(normalizeSkuCode(skuCode)) || {}; const originalImageUrl = getSkuSelectionImage(task, skuCode); const sourceImageKey = `sku:${normalizedSkuCode}`; const existingGeneratedImageUrl = normalizeImageUrl(existing.generatedImageUrl); const identityMatches = Boolean( normalizeSkuCode(existing.generatedForSkuCode) === normalizedSkuCode && normalizeText(existing.generatedForSkuName, 255) === skuName && normalizeImageUrl(existing.generatedFromImageUrl) === normalizeImageUrl(originalImageUrl) && normalizeSkuImageMode(existing.generationMode) === generationMode && (generationMode !== 'market_composite' || normalizeImageUrl(existing.marketReferenceImageUrl) === marketReferenceImageUrl) ); const shouldRetry = retrySet.has(normalizedSkuCode); const generatedImageUrl = identityMatches && !shouldRetry ? existingGeneratedImageUrl : ''; const qualityReviewRequired = requiresSkuImageQualityReview(generationMode); const preservesFailure = Boolean( identityMatches && !shouldRetry && !generatedImageUrl && existing.status === 'failed' ); return { ...existing, skuCode, skuName, originalImageUrl, sourceImageKey, generatedImageUrl, generationMode, marketReferenceImageUrl: generationMode === 'market_composite' ? marketReferenceImageUrl : '', generatedForSkuCode: normalizedSkuCode, generatedForSkuName: skuName, generatedFromImageUrl: normalizeImageUrl(originalImageUrl), requestId: generatedImageUrl ? normalizeText(existing.requestId, 255) : '', status: generatedImageUrl ? 'completed' : (preservesFailure ? 'failed' : 'waiting'), progress: generatedImageUrl || preservesFailure ? 100 : 0, error: preservesFailure ? normalizeText(existing.error, 1000) : '', sharedFromSkuCode: '', qualityReviewRequired, qualityReviewStatus: generatedImageUrl && identityMatches && !shouldRetry ? (existing.qualityReviewStatus || (qualityReviewRequired ? 'pending' : 'not_required')) : (qualityReviewRequired ? 'pending' : 'not_required'), qualityReviewedAt: generatedImageUrl && identityMatches && !shouldRetry ? normalizeText(existing.qualityReviewedAt, 100) : '', qualityReviewedBy: generatedImageUrl && identityMatches && !shouldRetry ? normalizeText(existing.qualityReviewedBy, 160) : '', }; }); return preparedRows; } function getVerifiedSkuImageRow(task = {}, skuCode = '') { const normalizedSkuCode = normalizeSkuCode(skuCode); if (!normalizedSkuCode) return null; const snapshotRow = (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) .find((row) => normalizeSkuCode(row.skuCode) === normalizedSkuCode); if (!snapshotRow) return null; const expectedSkuName = normalizeText( snapshotRow.skuName || snapshotRow.productName || snapshotRow.skuCode, 255 ); const expectedSourceImageUrl = normalizeImageUrl(getSkuSelectionImage(task, snapshotRow.skuCode)); const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); const expectedMarketReferenceImageUrl = generationMode === 'market_composite' ? normalizeImageUrl( task.skuSceneImages?.marketReference?.imageUrl || task.skuSceneImages?.marketReferenceImageUrl ) : ''; const row = (Array.isArray(task.skuSceneImages?.rows) ? task.skuSceneImages.rows : []) .find((candidate) => normalizeSkuCode(candidate.skuCode) === normalizedSkuCode); if (!row || !normalizeImageUrl(row.generatedImageUrl)) return null; if (normalizeSkuCode(row.generatedForSkuCode) !== normalizedSkuCode) return null; if (normalizeText(row.generatedForSkuName, 255) !== expectedSkuName) return null; if (normalizeImageUrl(row.generatedFromImageUrl) !== expectedSourceImageUrl) return null; if (normalizeSkuImageMode(row.generationMode) !== generationMode) return null; if (generationMode === 'market_composite' && normalizeImageUrl(row.marketReferenceImageUrl) !== expectedMarketReferenceImageUrl) { return null; } if (row.qualityReviewRequired && row.qualityReviewStatus !== 'approved') return null; return row; } async function syncVerifiedSkuImagesToPricingTable(task = {}, rows = []) { const verifiedRows = (Array.isArray(rows) ? rows : []) .filter((row) => getVerifiedSkuImageRow({ ...task, skuSceneImages: { ...(task.skuSceneImages || {}), rows, }, }, row.skuCode)); if (verifiedRows.length === 0) return; await batchUpdateSelectionPricingRows( planningDatabase, PRICING_TABLE, task.id, verifiedRows.map((row) => ({ skuCode: normalizeSkuCode(row.skuCode), skuImageUrl: normalizeImageUrl(row.originalImageUrl), generatedImageUrl: normalizeImageUrl(row.generatedImageUrl), })), [ ['sku_image_url', (row) => row.skuImageUrl || null], ['generated_image_url', (row) => row.generatedImageUrl || null], ] ); } function summarizeSkuSceneImageRows(rows = []) { const total = rows.length; const completedCount = rows.filter((row) => row.generatedImageUrl).length; const failedCount = rows.filter((row) => !row.generatedImageUrl && row.status === 'failed').length; const processedCount = completedCount + failedCount; return { total, completedCount, failedCount, processedCount, progress: total > 0 ? Math.round((processedCount / total) * 100) : 0, }; } function finalizeSourceSkuImageRows(rows = []) { return (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, originalImageUrl: normalizeImageUrl(row.originalImageUrl), generatedImageUrl: normalizeImageUrl(row.originalImageUrl), requestId: '', generationMode: 'source_original', marketReferenceImageUrl: '', generatedForSkuCode: normalizeSkuCode(row.skuCode), generatedForSkuName: normalizeText(row.skuName, 255), generatedFromImageUrl: normalizeImageUrl(row.originalImageUrl), status: 'completed', progress: 100, error: '', sharedFromSkuCode: '', qualityReviewRequired: false, qualityReviewStatus: 'not_required', qualityReviewedAt: '', qualityReviewedBy: '', })); } async function getSelectionFissionSkuMaterialAssets(planId, planItemId, skuCode) { const item = await getPlanItem(planId, planItemId); const normalizedSkuCode = normalizeText(skuCode, 160); const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) .find((row) => row.skuCode === normalizedSkuCode); const assetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []).find((row) => row.skuCode === normalizedSkuCode); if (!snapshotRow && !assetRow) { const error = new Error('该SKU不属于当前SPU'); error.status = 404; throw error; } const [whiteBackgroundResult, materialLibraryResult] = await Promise.allSettled([ fetchWhiteBackgroundSkuAssetDetails(normalizedSkuCode), fetchTaskDetailSkuAssets(normalizedSkuCode), ]); const whiteBackgroundAssets = whiteBackgroundResult.status === 'fulfilled' ? whiteBackgroundResult.value : { rows: [] }; const materialLibraryAssets = materialLibraryResult.status === 'fulfilled' ? materialLibraryResult.value : { rows: [] }; const originalUrl = normalizeImageUrl( assetRow?.originalPreviewImage?.previewUrl || snapshotRow?.skuImageUrl ); const rows = originalUrl ? [{ assetKey: 'original', group: 'original', key: 'original', label: '公司SKU原图', previewUrl: originalUrl, downloadUrl: originalUrl, fileName: '', fileSize: null, }, ...whiteBackgroundAssets.rows, ...materialLibraryAssets.rows] : [...whiteBackgroundAssets.rows, ...materialLibraryAssets.rows]; return { resourceType: 'sku-image-options', skuCode: normalizedSkuCode, skuName: snapshotRow?.skuName || assetRow?.skuName || normalizedSkuCode, currentAssetKey: assetRow?.selectedAssetKey || (originalUrl ? 'original' : ''), currentImageUrl: getSkuSelectionImage(item, normalizedSkuCode), total: rows.length, rows, groups: { original: rows.filter((row) => row.group === 'original').length, whiteBackground: rows.filter((row) => ['fixed', 'other'].includes(row.group)).length, materialLibrary: rows.filter((row) => row.group === 'material-library').length, }, warnings: [ whiteBackgroundResult.status === 'rejected' ? `白底图接口不可用:${whiteBackgroundResult.reason?.message || '未知错误'}` : '', materialLibraryResult.status === 'rejected' ? `素材库接口不可用:${materialLibraryResult.reason?.message || '未知错误'}` : '', ].filter(Boolean), }; } async function selectSelectionFissionSkuMaterialAsset(planId, planItemId, input = {}) { const item = await getPlanItem(planId, planItemId); const skuCode = normalizeText(input.skuCode || input.sku_code, 160); const assetKey = normalizeText(input.assetKey || input.asset_key, 255); if (!skuCode || !assetKey) { const error = new Error('请选择SKU和素材图片'); error.status = 400; throw error; } const snapshotRow = (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) .find((row) => row.skuCode === skuCode); const existingAssetRow = (Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []).find((row) => row.skuCode === skuCode); let selected = null; if (assetKey === 'original') { const originalUrl = normalizeImageUrl( existingAssetRow?.originalPreviewImage?.previewUrl || snapshotRow?.skuImageUrl ); selected = originalUrl ? { assetKey: 'original', group: 'original', label: '公司SKU原图', previewUrl: originalUrl, downloadUrl: originalUrl, } : null; } else { const materialAssets = await getSelectionFissionSkuMaterialAssets(planId, planItemId, skuCode); selected = materialAssets.rows.find((row) => row.assetKey === assetKey); } if (!selected?.previewUrl) { const error = new Error('选择的素材已失效,请重新查询素材库'); error.status = 400; throw error; } const whiteBackgroundAssets = item.whiteBackgroundAssets || { resourceType: 'company-sku-image', spuCode: item.selectedSpuCode, rows: [], }; const assetRows = Array.isArray(whiteBackgroundAssets.rows) ? whiteBackgroundAssets.rows.map((row) => ({ ...row })) : []; let targetRow = assetRows.find((row) => row.skuCode === skuCode); if (!targetRow) { targetRow = { skuCode, skuId: skuCode, skuName: snapshotRow?.skuName || skuCode, originalPreviewImage: snapshotRow?.skuImageUrl ? { previewUrl: snapshotRow.skuImageUrl, downloadUrl: snapshotRow.skuImageUrl, } : null, }; assetRows.push(targetRow); } targetRow.previewImage = { previewUrl: selected.previewUrl, downloadUrl: selected.downloadUrl || selected.previewUrl, fileName: selected.fileName || '', fileSize: selected.fileSize ?? null, }; targetRow.hasWhiteBackground = true; targetRow.whiteBackgroundCount = 1; targetRow.selectedImageSource = selected.group === 'original' ? 'company-sku' : selected.group === 'material-library' ? 'task-detail-material-library' : 'white-background-manual'; targetRow.selectedAssetKey = selected.assetKey; targetRow.selectedAssetLabel = selected.label; targetRow.selectedAt = new Date().toISOString(); const skuSceneImages = item.skuSceneImages && typeof item.skuSceneImages === 'object' ? { ...item.skuSceneImages } : null; if (skuSceneImages) { skuSceneImages.rows = (Array.isArray(skuSceneImages.rows) ? skuSceneImages.rows : []) .map((row) => row.skuCode === skuCode ? { ...row, originalImageUrl: selected.previewUrl, generatedImageUrl: '', requestId: '', status: 'waiting', progress: 0, } : row); skuSceneImages.updatedAt = new Date().toISOString(); } await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET white_background_assets_json = ?, sku_scene_images_json = ?, workflow_step = LEAST(workflow_step, 4), workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ ...whiteBackgroundAssets, rows: assetRows, updatedAt: new Date().toISOString(), }), safeJsonStringify(skuSceneImages), item.id, ] ); await syncSelectionFissionPlanWorkflowState(item.planId); return getSelectionFissionPlanDetail(planId); } async function loadCompanySkuSelectionAssets(planId, itemId, spuCode) { const snapshot = await loadAndStoreItemSkus(planId, itemId, spuCode); let supplementaryAssets = null; if (snapshot.rows.some((row) => !row.skuImageUrl)) { try { supplementaryAssets = await fetchWhiteBackgroundSkuAssets(spuCode); } catch (error) { snapshot.warnings = [ ...(snapshot.warnings || []), `补充白底图接口不可用:${error.message || '未知错误'}`, ]; } } return { snapshot, assets: buildSkuSelectionAssetsFromSnapshot(snapshot, supplementaryAssets), }; } function getVideoAssetsConfig() { const baseUrl = stripTrailingSlash(getFirstEnvValue([ 'SELECTION_VIDEO_ASSETS_BASE_URL', 'VIDEO_ASSETS_BASE_URL', ], 'https://sc.jiansun.vip')); const apiKey = getFirstEnvValue([ 'SELECTION_VIDEO_ASSETS_API_KEY', 'VIDEO_ASSETS_API_KEY', ]); return { baseUrl, apiKey, timeoutMs: normalizePositiveInteger(process.env.SELECTION_VIDEO_ASSETS_TIMEOUT_MS, DEFAULT_EXTERNAL_TIMEOUT_MS), }; } async function fetchVideoAssets(keyword) { const normalizedKeyword = normalizeText(keyword, 160); if (!normalizedKeyword) return { items: [], warning: '缺少SPU,无法查询视频素材' }; const config = getVideoAssetsConfig(); if (!config.baseUrl || !config.apiKey) { return { items: [], warning: '视频素材接口未配置 SELECTION_VIDEO_ASSETS_API_KEY' }; } try { const response = await axios.get(`${config.baseUrl}/api/external/video-assets`, { timeout: config.timeoutMs, params: { keyword: normalizedKeyword }, headers: { 'X-API-Key': config.apiKey }, }); const data = response.data?.data || response.data || {}; return { items: Array.isArray(data.items) ? data.items : [], total: Number(data.total || data.items?.length || 0), raw: response.data, }; } catch (error) { return { items: [], warning: error.response?.data?.message || error.message || '视频素材查询失败', }; } } async function requestSelectionGeneratedImage({ prompt, referenceImages, metadata, negativePrompt, aspectRatio, }) { const sourceImages = (Array.isArray(referenceImages) ? referenceImages : []) .map(normalizeImageUrl) .filter(Boolean); if (sourceImages.length === 0) { return { imageUrl: '', warning: '缺少生图参考图片,已跳过', }; } let images; try { images = await Promise.all( sourceImages.map((imageUrl) => normalizeImageGenerationReferenceUrl(imageUrl)) ); } catch (error) { return { imageUrl: '', warning: `生图参考图片标准化失败:${getExternalRequestErrorMessage(error, '图片处理失败')}`, }; } const resolvedAspectRatio = normalizeText(aspectRatio, 32) || await getImageAspectRatio(images[0]) || '1:1'; const retryCount = normalizePositiveInteger( process.env.SELECTION_IMAGE_GEN_RETRY_COUNT, 2, 4 ); for (let attempt = 0; attempt <= retryCount; attempt += 1) { try { const generated = await generateSelectionImage({ prompt, negativePrompt: negativePrompt || '不要水印,不要畸变,不要错误文字,不要多余商品', aspectRatio: resolvedAspectRatio, referenceImages: images, metadata: { ...(metadata || {}), referenceAspectRatio: resolvedAspectRatio, }, }); return { imageUrl: normalizeImageUrl(generated.imageUrl), requestId: normalizeText(generated.requestId, 160), aspectRatio: resolvedAspectRatio, provider: generated.provider, model: generated.model, usage: generated.usage, raw: generated.raw, }; } catch (error) { const status = Number(error.response?.status || error.status || 0); if (status === 429 && attempt < retryCount) { await wait(3000 * (2 ** attempt)); continue; } return { imageUrl: '', warning: getExternalRequestErrorMessage(error, 'SKU图处理失败'), }; } } return { imageUrl: '', warning: 'SKU图处理失败' }; } function buildMainImageReferenceImages(whiteBackgroundImageUrl, marketImageUrl, marketCompositionFirst = false) { const productImageUrl = normalizeImageUrl(whiteBackgroundImageUrl); const compositionImageUrl = normalizeImageUrl(marketImageUrl); // Product identity must always be the strongest reference. The market // image supplies composition and styling only. return Array.from(new Set([productImageUrl, compositionImageUrl].filter(Boolean))); } function buildRigidProductIdentityPrompt({ productLabel = '目标商品', skuName = '' } = {}) { return [ `以下是不可被任何风格提示覆盖的${productLabel}身份锁定规则,优先级高于构图、机位、灯光、道具和背景要求。`, '参考图1中的商品必须被视为一个不可重新设计、不可拆分生成的刚性三维整体;只允许对整个商品做统一的透视、缩放、旋转和平移,严禁逐部件重绘、变形或重新组装。', '成品必须逐项保持参考图1的轮廓、几何结构、长宽高比例、厚度、开口、边缘、连接关系、部件位置、部件精确数量、颜色、材质、纹理、五金和全部配件不变。', '参考图2只能提供场景、机位、透视、裁切、背景、灯光、阴影和道具;不得继承参考图2商品的任何形状、结构、部件、颜色、材质或装饰。', '若复刻参考图2的机位会导致商品几何结构发生变化,必须优先保持参考图1商品不变,只采用最接近且不会改变商品结构的机位。', '输出前必须自检并逐一核对可见的腿、抽屉、层板、柜门、面板、横梁、把手、孔位、轮子、接口和附件,数量及相互位置必须与参考图1完全一致。', '严禁凭常识补全参考图1中不存在的脚轮、万向轮、支脚、把手、抽屉、柜门、层板、横梁、孔位、接口或装饰件,也严禁移除参考图1中已经存在的任何一项。', skuName ? `SKU规格“${skuName}”是不可放宽的身份约束:其中的尺寸、数量、颜色、款式和配件信息必须保持一致;禁止作为图片文字渲染,可见商品结构仍以参考图1为最高依据。` : '', '如果模型无法在参考场景中同时满足全部身份约束,应保留参考图1的商品构图并仅做安全的背景适配,不得猜测、补画或改造商品。', '任何其他提示与本身份锁定规则冲突时,必须无条件遵守本规则。最终商品必须可被确认是参考图1中的同一件商品,而不是相似款、改款或重新设计款。', ].filter(Boolean); } function normalizeMainImageRetryIndexes(value, maxIndex = 5) { if (!Array.isArray(value)) return []; const normalizedMaxIndex = Math.max(1, Number(maxIndex) || 5); return Array.from(new Set(value .map((index) => Number(index)) .filter((index) => Number.isInteger(index) && index >= 1 && index <= normalizedMaxIndex))); } async function generateMainReplacementImage({ marketImageUrl, whiteBackgroundImageUrl, marketItemId, spuCode, skuCode, skuName, aspectRatio, marketCompositionFirst = false, strictProductIdentity = false, }) { const aiConfig = strictProductIdentity ? null : await getSelectionFissionAiConfig(); const referenceIdentityPrompt = [ '本次输入图像的强制定义:参考图1是目标商品图,是唯一商品身份来源;参考图2是同品类市场商品图,只是构图和视觉风格来源。', '目标商品必须保留原有外形、结构、颜色、材质、比例、接口、层数、配件和数量。若参考图1带有原始场景或杂乱背景,只清理背景,不得把背景中的物体误认成商品结构。', '强参考图2的机位、视角、裁切比例、商品在画面中的占比、空间布局、陈列方式、镜头景深、灯光方向、色温、背景材质和接触阴影;将目标商品替换到参考图2的主体位置,形成同样的商业摄影构图。', ]; const strictCompositePrompt = [ 'Use case: precise-object-edit / compositing. This is an exact product-compositing edit, not a redesign and not a similar-product generation.', 'Image 1 is the target 1688 product and the only source of product identity. Image 2 is the market reference scene and is only a source of camera pose, perspective, crop, placement, background, lighting and props.', 'Remove the entire market product from Image 2. Insert the complete target product from Image 1 into the exact same footprint.', 'Match Image 2 camera pose precisely: same camera height, azimuth, elevation, focal-length perspective, vanishing lines, crop and product scale. Transform the whole target product as one rigid 3D object; never transform or regenerate its parts independently.', 'Preserve every visible invariant from Image 1: all parts, exact part counts, positions, connections, color, material, thickness, proportions and accessories. Never redesign, simplify, add, delete, merge, split or relocate any component. Do not transfer any structure from the market product.', skuName ? `Target SKU specification: ${skuName}. This text supplements Image 1 and must never override its visible geometry.` : '', 'Before output, count every visible drawer, leg, shelf, panel, frame and accessory in Image 1 and verify the output has the same exact components in the same relationships.', 'If exact camera matching risks changing product geometry, preserve product geometry first and use the closest valid camera pose. The final image must be unmistakably the exact Image 1 product, photographed from the Image 2 angle.', 'No text, labels, logos, prices, badges or watermarks from Image 2 may remain.', ].filter(Boolean); const standardCompositePrompt = [ '强制图像角色定义:参考图1是本次要上架的目标商品,是唯一允许出现在成品中的商品主体和商品身份来源。', '参考图2是市场商品主图,只允许参考其机位、视角、裁切、主体占比、空间布局、背景、灯光、道具和商业摄影风格;严禁复制或保留参考图2中的商品主体。', '必须先完整移除参考图2中的市场商品,再把参考图1中的目标商品放到相同主体位置。成品商品必须与参考图1在外形、桌面形状、桌腿结构、抽屉数量与位置、横梁、颜色、材质、比例和配件上保持一致。', '市场商品与目标商品存在结构冲突时,无条件以参考图1为准,不得把参考图2商品的任何结构迁移到目标商品上。', aiConfig?.mainImagePrompt, ...referenceIdentityPrompt, '必须彻底删除市场参考图中的商品主体及品牌信息,但可以复用其场景、道具、光影和氛围。最终效果应是“公司商品主体 + 市场图构图与风格”,而不是白底图加一个泛化背景。', ].filter(Boolean); return requestSelectionGeneratedImage({ prompt: [ ...(strictProductIdentity ? strictCompositePrompt : standardCompositePrompt), marketItemId ? `市场商品ID:${marketItemId}` : '', spuCode ? `公司SPU:${spuCode}` : '', skuCode ? `主图展示SKU:${skuCode}` : '', ...buildRigidProductIdentityPrompt({ productLabel: '主图目标商品', skuName }), ].filter(Boolean).join('\n'), referenceImages: buildMainImageReferenceImages( whiteBackgroundImageUrl, marketImageUrl, marketCompositionFirst ), negativePrompt: strictProductIdentity ? 'product redesign, missing components, added components, changed component count, changed legs, changed drawers, copied market product structure, cabinet, shelf, crossbar, altered proportions, altered material, extra product, text, logo, watermark, distorted geometry' : '禁止保留、复刻或混合市场商品主体,禁止把市场商品的结构移植到公司商品上,禁止白底棚拍、空洞纯色背景或与参考图2完全不同的构图,禁止改变公司商品的结构、颜色、材质、比例和配件,禁止多余商品、文字、Logo、水印、畸变和错误文字', aspectRatio, metadata: { source: 'shujuku3_selection_fission', task: 'main_image_replacement', referenceOrder: 'target-product-first', targetProductReferenceIndex: 1, marketCompositionReferenceIndex: 2, marketItemId, spuCode, skuCode, preserveProductIdentity: true, disablePromptExtend: true, identityLockVersion: 'rigid-product-v2', }, }); } async function generateCleanProductImage({ sourceImageUrl, marketItemId, aspectRatio }) { return requestSelectionGeneratedImage({ prompt: [ '这是精确商品图片清理任务,不是商品重绘或重新设计。', '完整保留原图中的商品主体、结构、部件数量、颜色、材质、比例、视角、裁切、背景和光影。', '只移除图片中的文字、品牌标志、店铺标志、价格、促销标签、角标、水印、二维码和图标覆盖物。', '被覆盖区域应按周围真实纹理自然修复,不得增加、删除、移动或改变任何商品部件。', marketItemId ? `1688商品ID:${marketItemId}` : '', ].filter(Boolean).join('\n'), referenceImages: [sourceImageUrl], negativePrompt: '商品重设计,结构变化,部件缺失,新增部件,颜色变化,材质变化,视角变化,裁切变化,文字,logo,水印,价格,促销标签,二维码,图标', aspectRatio, metadata: { source: 'shujuku3_selection_fission', task: 'alibaba_1688_clean_source_image', marketItemId, }, }); } async function generateCleanSkuImage({ skuImageUrl, skuCode, skuName, spuCode, aspectRatio }) { return requestSelectionGeneratedImage({ prompt: [ '这是SKU图片清理任务,不是主图生成、场景重绘或商品重新设计。', '唯一参考图就是当前SKU的目标图,必须原样保留该SKU商品的结构、规格、颜色、材质、尺寸比例、部件、配件数量、视角和裁切。', '只移除图中的文字、品牌标志、店铺标志、价格、促销标签、角标、水印、二维码和图标覆盖物,并自然修复被覆盖区域。', '禁止改成其他SKU,禁止引用主图商品,禁止增加、删除、移动或改变商品部件。', skuCode ? `内部SKU身份编码:${skuCode},只用于身份校验,禁止渲染到图片中。` : '', skuName ? `内部SKU身份名称:${skuName},只用于身份校验,禁止渲染到图片中。` : '', ].filter(Boolean).join('\n'), referenceImages: [skuImageUrl], negativePrompt: '主图商品,其他SKU,商品重设计,结构变化,规格变化,颜色变化,部件缺失,新增部件,文字,logo,水印,价格,二维码,图标', aspectRatio, metadata: { source: 'shujuku3_selection_fission', task: 'sku_clean_image', spuCode, skuCode, skuName, }, }); } async function generateSkuSceneImage({ marketReferenceImageUrl, skuImageUrl, skuCode, skuName, title, spuCode, aspectRatio, }) { const aiConfig = await getSelectionFissionAiConfig(); return requestSelectionGeneratedImage({ prompt: [ '这是逐SKU场景替换任务,不是主图批量生成。第一张图是当前SKU唯一商品身份图,第二张图只提供场景、构图、机位和光线参考。', '必须完整移除第二张图中的原商品,只把第一张图的当前SKU商品放入参考场景;禁止保留、混合或复刻参考图商品。', '必须准确保留当前SKU的结构、规格、颜色、材质、尺寸比例、部件、配件数量和可见细节,禁止改成同SPU下其他SKU。', aiConfig.skuImagePrompt, spuCode ? `公司SPU:${spuCode}` : '', skuCode ? `内部SKU身份编码:${skuCode},只用于身份校验,禁止渲染到图片中。` : '', skuName ? `内部SKU身份名称:${skuName},只用于身份校验,禁止渲染到图片中。` : '', title ? `市场标题只作类目语义参考:${title}` : '', '输出一张且仅一张无文字、无Logo、无水印的正方形高清电商SKU图。', ...buildRigidProductIdentityPrompt({ productLabel: '当前SKU商品', skuName }), ].filter(Boolean).join('\n'), referenceImages: [skuImageUrl, marketReferenceImageUrl], negativePrompt: '参考图原商品,主图商品,其他SKU,商品混合,结构变化,规格变化,颜色变化,部件缺失,新增部件,新增脚轮,新增万向轮,新增支脚,新增把手,新增抽屉,新增层板,删除脚轮,删除支脚,删除把手,删除抽屉,删除层板,尺寸变化,数量变化,文字,logo,水印,价格,二维码,图标', aspectRatio, metadata: { source: 'shujuku3_selection_fission', task: 'sku_scene_image', spuCode, skuCode, skuName, referenceOrder: 'target-sku-first', targetSkuReferenceIndex: 1, marketCompositionReferenceIndex: 2, preserveProductIdentity: true, disablePromptExtend: true, identityLockVersion: 'rigid-product-v2', }, }); } async function updatePlanWorkflowState(planId, input = {}) { const status = ['draft', 'running', 'waiting', 'completed', 'failed'].includes(input.status) ? input.status : 'running'; const step = Math.min(Math.max(Number(input.step) || 0, 0), 8); const progress = Math.min(Math.max(Number(input.progress) || 0, 0), 100); const errorMessage = normalizeText(input.errorMessage, 1000); await planningDatabase.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET workflow_status = ?, workflow_step = ?, workflow_progress = ?, workflow_error_message = ?, workflow_started_at = CASE WHEN ? IN ('running', 'waiting', 'completed') THEN COALESCE(workflow_started_at, NOW()) ELSE workflow_started_at END, workflow_completed_at = CASE WHEN ? = 'completed' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = ?`, [ status, step, progress, errorMessage || null, status, status, normalizePositiveInteger(planId, 0), ] ); } async function updateItemWorkflowState(planItemId, input = {}) { const status = ['draft', 'running', 'waiting', 'completed', 'failed'].includes(input.status) ? input.status : 'running'; const step = Math.min(Math.max(Number(input.step) || 0, 0), 8); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET workflow_status = ?, workflow_step = ?, workflow_error_message = ?, updated_at = NOW() WHERE id = ?`, [ status, step, normalizeText(input.errorMessage, 1000) || null, normalizePositiveInteger(planItemId, 0), ] ); } async function processInitialSelectionWorkflow(planId, user = {}) { await updatePlanWorkflowState(planId, { status: 'running', step: 1, progress: 2, }); let plan = await getSelectionFissionPlanDetail(planId); const sourceItems = plan.sourceItems; for (let index = 0; index < sourceItems.length; index += 1) { const source = sourceItems[index]; await updateItemWorkflowState(source.id, { status: 'running', step: 1 }); const profile = await refreshPlanItemMarketProfile(source.id, user); if (profile?.warning && !source.mainImageUrl) { await updateItemWorkflowState(source.id, { status: 'failed', step: 1, errorMessage: profile.warning, }); throw createExternalServiceError( `${source.marketItemId} 获取市场主图失败:${profile.warning}`, 502, 'MARKET_IMAGE_FAILED' ); } await updateItemWorkflowState(source.id, { status: 'completed', step: 1 }); await updatePlanWorkflowState(planId, { status: 'running', step: 1, progress: 2 + ((index + 1) / Math.max(sourceItems.length, 1)) * 10, }); } await updatePlanWorkflowState(planId, { status: 'running', step: 2, progress: 14, }); plan = await getSelectionFissionPlanDetail(planId); for (let index = 0; index < plan.sourceItems.length; index += 1) { const source = plan.sourceItems[index]; await updateItemWorkflowState(source.id, { status: 'running', step: 2 }); try { await searchSelectionFissionSimilarProducts(planId, source.id, { imageUrl: source.mainImageUrl, topK: 100, includeRaw: true, }, user); try { await searchSelectionFissionMarketReferences(planId, source.id, { imageUrl: source.mainImageUrl, batchNo: 1, }, user); } catch (error) { console.warn('[SelectionFissionWorkflow] 市场参考款预加载失败,暂不阻断公司SPU选择:', error.message || error); } await updateItemWorkflowState(source.id, { status: 'waiting', step: 2 }); } catch (error) { await updateItemWorkflowState(source.id, { status: 'failed', step: 2, errorMessage: error.message, }); } await updatePlanWorkflowState(planId, { status: 'running', step: 2, progress: 14 + ((index + 1) / Math.max(plan.sourceItems.length, 1)) * 6, }); } await updatePlanWorkflowState(planId, { status: 'waiting', step: 2, progress: 20, }); } async function processMainSkuPreparation(planId) { await updatePlanWorkflowState(planId, { status: 'running', step: 3, progress: 22, }); const plan = await getSelectionFissionPlanDetail(planId); const tasks = plan.listingTasks; for (let index = 0; index < tasks.length; index += 1) { const task = tasks[index]; await updateItemWorkflowState(task.id, { status: 'running', step: 3 }); try { const { snapshot: skuSnapshot, assets: whiteBackgroundAssets } = await loadCompanySkuSelectionAssets( planId, task.id, task.selectedSpuCode ); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_snapshot_json = ?, white_background_assets_json = ?, workflow_status = 'waiting', workflow_step = 3, workflow_error_message = ?, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(skuSnapshot), safeJsonStringify(whiteBackgroundAssets), whiteBackgroundAssets.rows.some((row) => row.previewImage?.previewUrl) ? null : '公司SKU接口未返回可选择的SKU图片', task.id, ] ); } catch (error) { await updateItemWorkflowState(task.id, { status: 'failed', step: 3, errorMessage: error.message, }); } await updatePlanWorkflowState(planId, { status: 'running', step: 3, progress: 22 + ((index + 1) / Math.max(tasks.length, 1)) * 8, }); } await updatePlanWorkflowState(planId, { status: 'waiting', step: 3, progress: 30, }); } async function generateWorkflowMainImage(task) { const isAlibaba1688Pending = getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE; const maxReferenceCount = getMaxMainImageCount(task); const mainImageMode = normalizeAlibaba1688MainImageMode( task.mainImageGeneration?.mode, 'market_composite' ); const configuredSourceImages = Array.isArray(task.mainImageGeneration?.sourceImageUrls) ? task.mainImageGeneration.sourceImageUrls : []; const sourceImageCount = getConfiguredMainImageCount(task, 1); const fallbackSourceImages = isAlibaba1688Pending ? getAlibaba1688SourceImages(task).mainImages : [getMainGenerationSourceImage(task)]; const sourceReferences = (configuredSourceImages.length > 0 ? configuredSourceImages : fallbackSourceImages) .map(normalizeImageUrl) .filter(Boolean) .slice(0, sourceImageCount) .map((imageUrl, index) => ({ id: `source-${index + 1}`, imageUrl, marketItemId: task.marketItemId, })); const marketReferences = (Array.isArray(task.selectedMarketReferences) ? task.selectedMarketReferences : []) .map((reference, index) => ({ id: reference.id || `reference-${index + 1}`, imageUrl: normalizeImageUrl(reference.imageUrl), marketItemId: reference.marketItemId || task.marketItemId, })) .filter((reference) => reference.imageUrl) .slice(0, maxReferenceCount); const references = mainImageMode === 'market_composite' ? marketReferences : sourceReferences; if (references.length < 1) { throw createExternalServiceError( mainImageMode === 'market_composite' ? `生成主图前必须确认 1 至 ${maxReferenceCount} 个市场参考款` : '1688源商品没有可用主图', 400, mainImageMode === 'market_composite' ? 'MAIN_IMAGE_REFERENCES_REQUIRED' : 'ALIBABA_1688_SOURCE_IMAGES_REQUIRED' ); } const existingRows = Array.isArray(task.mainImageGeneration?.rows) ? task.mainImageGeneration.rows : []; const rows = references.map((reference, index) => { const existing = existingRows.find((row) => row.referenceId === reference.id); return existing?.imageUrl ? existing : { index: index + 1, referenceId: reference.id, referenceImageUrl: reference.imageUrl, imageUrl: mainImageMode === 'source_original' ? reference.imageUrl : '', requestId: '', status: mainImageMode === 'source_original' ? 'completed' : 'waiting', progress: mainImageMode === 'source_original' ? 100 : 0, error: '', qualityReviewRequired: requiresMainImageQualityReview(mainImageMode), qualityReviewStatus: requiresMainImageQualityReview(mainImageMode) ? 'pending' : 'not_required', }; }); const persist = async (status, errorMessage = '') => { const firstImageUrl = rows.find((row) => row.imageUrl)?.imageUrl || ''; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET replaced_image_url = COALESCE(NULLIF(?, ''), replaced_image_url), main_image_generation_json = ?, workflow_step = 3, workflow_status = ?, workflow_error_message = ?, updated_at = NOW() WHERE id = ?`, [firstImageUrl, safeJsonStringify({ mode: mainImageMode, requestedCount: references.length, sourceImageUrls: mainImageMode === 'market_composite' ? configuredSourceImages : references.map((reference) => reference.imageUrl), total: references.length, rows, updatedAt: new Date().toISOString(), }), status, errorMessage || null, task.id] ); }; if (mainImageMode === 'source_original') { await persist('completed'); return; } await persist('running'); const warnings = []; let persistQueue = Promise.resolve(); const enqueuePersist = (errorMessage = '') => { persistQueue = persistQueue.then(() => persist('running', errorMessage)); return persistQueue; }; // Generate up to five main images concurrently. A batch-level persist keeps // the upstream service busy while a small queue serializes progress writes. for (let batchStart = 0; batchStart < rows.length; batchStart += MAIN_IMAGE_GENERATION_CONCURRENCY) { const batchIndexes = rows .map((row, index) => ({ row, index })) .filter(({ row, index }) => index >= batchStart && index < batchStart + MAIN_IMAGE_GENERATION_CONCURRENCY && !row.imageUrl) .map(({ index }) => index); if (batchIndexes.length === 0) continue; batchIndexes.forEach((index) => { rows[index] = { ...rows[index], status: 'processing', progress: 20, error: '', startedAt: new Date().toISOString(), completedAt: '', }; }); await persist('running'); await Promise.all(batchIndexes.map(async (index) => { try { const aspectRatio = await getImageAspectRatio(references[index].imageUrl); const generated = mainImageMode === 'clean_only' ? await generateCleanProductImage({ sourceImageUrl: references[index].imageUrl, marketItemId: references[index].marketItemId, aspectRatio, }) : await generateMainReplacementImage({ marketImageUrl: references[index].imageUrl, whiteBackgroundImageUrl: getMainGenerationSourceImage(task), marketItemId: references[index].marketItemId, spuCode: task.selectedSpuCode, skuCode: task.selectedMainSkuCode, skuName: (Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []) .find((row) => normalizeSkuCode(row.skuCode) === normalizeSkuCode(task.selectedMainSkuCode))?.skuName || '', marketCompositionFirst: false, // Market-composite images are never allowed to reinterpret the // product, regardless of whether the source is company or 1688. strictProductIdentity: true, aspectRatio, }); if (!generated.imageUrl) throw new Error(generated.warning || '主图生图接口未返回图片'); rows[index] = { ...rows[index], imageUrl: generated.imageUrl, requestId: generated.requestId || '', status: 'completed', progress: 100, error: '', completedAt: new Date().toISOString(), qualityReviewRequired: requiresMainImageQualityReview(mainImageMode), qualityReviewStatus: requiresMainImageQualityReview(mainImageMode) ? 'pending' : 'not_required', qualityReviewedAt: '', qualityReviewedBy: '', }; await enqueuePersist(); } catch (error) { const message = error?.message || '主图生图失败'; rows[index] = { ...rows[index], status: 'failed', progress: 100, error: message, completedAt: new Date().toISOString(), }; warnings.push(`第 ${index + 1} 张:${message}`); await enqueuePersist(message); } })); await persistQueue; } if (rows.some((row) => !row.imageUrl)) { await persist('failed', warnings.join(';') || '部分主图生成失败'); throw createExternalServiceError(warnings[0] || '部分主图生成失败', 502, 'MAIN_IMAGE_GENERATION_FAILED'); } await persist(requiresMainImageQualityReview(mainImageMode) ? 'waiting' : 'completed'); } async function confirmSelectionFissionMainImageReview(planId, planItemId, user = {}) { const item = await getPlanItem(planId, planItemId); const generation = item.mainImageGeneration || {}; const rows = Array.isArray(generation.rows) ? generation.rows : []; const requiredCount = getConfiguredMainImageCount(item, rows.length); if (rows.length < 1 || rows.length !== requiredCount || rows.some((row) => !normalizeImageUrl(row.imageUrl))) { const error = new Error('主图尚未全部生成,暂时无法确认验收'); error.status = 409; throw error; } const reviewedAt = new Date().toISOString(); const reviewedBy = normalizeText( user?.name || user?.nickname || user?.email || user?.username || user?.id, 160 ); const reviewedRows = rows.map((row) => ({ ...row, qualityReviewRequired: Boolean(row.qualityReviewRequired), qualityReviewStatus: row.qualityReviewRequired ? 'approved' : (row.qualityReviewStatus || 'not_required'), qualityReviewedAt: row.qualityReviewRequired ? reviewedAt : (row.qualityReviewedAt || ''), qualityReviewedBy: row.qualityReviewRequired ? reviewedBy : (row.qualityReviewedBy || ''), })); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET main_image_generation_json = ?, workflow_status = 'completed', workflow_step = 3, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ ...generation, rows: reviewedRows, qualityReviewStatus: 'approved', qualityReviewedAt: reviewedAt, qualityReviewedBy: reviewedBy, updatedAt: reviewedAt, }), item.id, ] ); await syncSelectionFissionPlanWorkflowState(item.planId); return getSelectionFissionPlanDetail(item.planId); } async function generateWorkflowSkuImages(task) { const skuRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; const selectedCodes = new Set(getWorkflowSelectedSkuCodes(task).map(normalizeSkuCode)); const selectedSkuRows = skuRows.filter((sku) => ( selectedCodes.has(normalizeSkuCode(sku.skuCode)) && getSkuSelectionImage(task, sku.skuCode) )); if (selectedSkuRows.length === 0) { const error = new Error('当前商品没有可用于生图的 SKU 素材'); error.status = 409; throw error; } const retrySkuCodes = Array.isArray(task.skuSceneImages?.retrySkuCodes) ? task.skuSceneImages.retrySkuCodes.map((code) => normalizeText(code, 160)).filter(Boolean) : []; const retrySet = new Set(retrySkuCodes.map(normalizeSkuCode)); const retryMode = retrySet.size > 0; const generationMode = normalizeSkuImageMode(task.skuSceneImages?.mode); const generationRunId = normalizeText(task.skuSceneImages?.generationRunId, 100) || crypto.randomUUID(); const marketReference = task.skuSceneImages?.marketReference || null; const marketReferenceImageUrl = normalizeImageUrl( marketReference?.imageUrl || task.skuSceneImages?.marketReferenceImageUrl ); if (generationMode === 'market_composite' && !marketReferenceImageUrl) { const error = new Error('参考市场图生成模式必须选择 1 张市场参考图'); error.status = 400; throw error; } const rows = prepareSkuSceneImageRows(task, selectedSkuRows, retrySkuCodes); const sourceGroups = groupSkuSceneRowsBySourceImage(rows); const batches = splitIntoBatches(sourceGroups); const warnings = (Array.isArray(task.skuSceneImages?.warnings) ? task.skuSceneImages.warnings : []) .filter((warning) => !retrySkuCodes.some((skuCode) => String(warning).startsWith(`${skuCode}:`))); const persist = async (status, errorMessage = '', currentBatch = 0) => { const summary = summarizeSkuSceneImageRows(rows); const requestSummary = summarizeSkuSceneImageRequests(rows); const result = await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = ?, workflow_step = 4, workflow_status = ?, workflow_error_message = ?, updated_at = NOW() WHERE id = ? AND ( JSON_UNQUOTE(JSON_EXTRACT(sku_scene_images_json, '$.generationRunId')) = ? OR JSON_EXTRACT(sku_scene_images_json, '$.generationRunId') IS NULL )`, [ safeJsonStringify({ ...summary, ...requestSummary, generationRunId, mode: generationMode, marketReference: generationMode === 'market_composite' ? marketReference : null, selectedSkuCodes: selectedSkuRows.map((sku) => sku.skuCode), retrySkuCodes: status === 'running' ? retrySkuCodes : [], batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, batchTotal: batches.length, currentBatch, rows, warnings, updatedAt: new Date().toISOString(), generatedAt: status === 'completed' ? new Date().toISOString() : task.skuSceneImages?.generatedAt, }), status, errorMessage || null, task.id, generationRunId, ] ); if (Number(result?.affectedRows || 0) === 0) { throw createSkuImageRunSupersededError(); } }; await persist('running'); for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { const batch = batches[batchIndex]; const pendingGroups = batch.filter((group) => ( retryMode ? group.rows.some((row) => retrySet.has(normalizeSkuCode(row.skuCode))) : group.rows.some((row) => !row.generatedImageUrl) )); if (pendingGroups.length === 0) continue; pendingGroups.forEach((group) => { group.rows.forEach((row) => { row.status = 'processing'; row.progress = 20; row.error = ''; }); }); await persist('running', '', batchIndex + 1); const generatedGroups = await Promise.all(pendingGroups.map(async (group) => { const representative = group.rows[0]; const sourceImageUrl = group.sourceImageUrl || getSkuSelectionImage(task, representative.skuCode); if (!sourceImageUrl) { return { group, error: '未选择可用SKU素材图' }; } try { if (generationMode === 'source_original') { return { group, generated: { imageUrl: sourceImageUrl, requestId: '', warning: '' }, }; } const aspectRatio = await getImageAspectRatio( generationMode === 'market_composite' ? marketReferenceImageUrl : sourceImageUrl ); const generated = generationMode === 'clean_only' ? await generateCleanSkuImage({ skuImageUrl: sourceImageUrl, skuCode: representative.skuCode, skuName: representative.skuName, spuCode: task.selectedSpuCode, aspectRatio, }) : await generateSkuSceneImage({ marketReferenceImageUrl, skuImageUrl: sourceImageUrl, skuCode: representative.skuCode, skuName: representative.skuName, title: marketReference?.title || task.title, spuCode: task.selectedSpuCode, aspectRatio, }); if (!generated.imageUrl) { return { group, error: generated.warning || 'SKU图处理接口未返回图片' }; } return { group, generated }; } catch (error) { return { group, error: error.message || 'SKU图处理失败' }; } })); generatedGroups.forEach(({ group, generated, error }) => { const skuCodes = group.rows.map((row) => row.skuCode); group.rows.forEach((row) => { if (generated?.imageUrl) { row.originalImageUrl = group.sourceImageUrl || getSkuSelectionImage(task, row.skuCode); row.generatedImageUrl = generated.imageUrl; row.requestId = generated.requestId || ''; row.generationMode = generationMode; row.marketReferenceImageUrl = generationMode === 'market_composite' ? marketReferenceImageUrl : ''; row.generatedForSkuCode = normalizeSkuCode(row.skuCode); row.generatedForSkuName = normalizeText(row.skuName, 255); row.generatedFromImageUrl = normalizeImageUrl(row.originalImageUrl); row.status = 'completed'; row.progress = 100; row.error = ''; row.sharedFromSkuCode = ''; row.qualityReviewRequired = requiresSkuImageQualityReview(generationMode); row.qualityReviewStatus = row.qualityReviewRequired ? 'pending' : 'not_required'; row.qualityReviewedAt = ''; row.qualityReviewedBy = ''; return; } row.status = 'failed'; row.progress = 100; row.error = error; }); if (generated?.warning) warnings.push(`${skuCodes.join('、')}: ${generated.warning}`); if (error) warnings.push(`${skuCodes.join('、')}: ${error}`); }); await persist('running', generatedGroups.find((result) => result.error)?.error || '', batchIndex + 1); } const missingRows = rows.filter((row) => !row.generatedImageUrl); if (missingRows.length > 0) { const errorMessage = missingRows.find((row) => row.error)?.error || warnings[0] || `${missingRows.map((row) => row.skuCode).join('、')} SKU图处理失败`; await persist('failed', errorMessage, batches.length); throw createExternalServiceError( errorMessage, 502, 'SKU_IMAGE_GENERATION_FAILED' ); } await persist(requiresSkuImageQualityReview(generationMode) ? 'waiting' : 'completed', '', batches.length); await syncVerifiedSkuImagesToPricingTable({ ...task, skuSceneImages: { ...(task.skuSceneImages || {}), mode: generationMode, marketReference: generationMode === 'market_composite' ? marketReference : null, rows, }, }, rows); } async function confirmSelectionFissionSkuImageReview(planId, planItemId, user = {}) { const item = await getPlanItem(planId, planItemId); const generation = item.skuSceneImages || {}; const selectedCodes = new Set(getWorkflowSelectedSkuCodes(item).map(normalizeSkuCode)); const rows = Array.isArray(generation.rows) ? generation.rows : []; const selectedRows = rows.filter((row) => selectedCodes.has(normalizeSkuCode(row.skuCode))); if (selectedRows.length < 1 || selectedRows.some((row) => !normalizeImageUrl(row.generatedImageUrl))) { const error = new Error('SKU 图尚未全部生成,暂时无法确认验收'); error.status = 409; throw error; } const reviewedAt = new Date().toISOString(); const reviewedBy = normalizeText( user?.name || user?.nickname || user?.email || user?.username || user?.id, 160 ); const reviewedRows = rows.map((row) => { if (!selectedCodes.has(normalizeSkuCode(row.skuCode))) return row; return { ...row, qualityReviewRequired: Boolean(row.qualityReviewRequired), qualityReviewStatus: row.qualityReviewRequired ? 'approved' : (row.qualityReviewStatus || 'not_required'), qualityReviewedAt: row.qualityReviewRequired ? reviewedAt : (row.qualityReviewedAt || ''), qualityReviewedBy: row.qualityReviewRequired ? reviewedBy : (row.qualityReviewedBy || ''), }; }); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = ?, workflow_status = 'completed', workflow_step = 4, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ ...generation, rows: reviewedRows, qualityReviewStatus: 'approved', qualityReviewedAt: reviewedAt, qualityReviewedBy: reviewedBy, updatedAt: reviewedAt, }), item.id, ] ); await syncVerifiedSkuImagesToPricingTable({ ...item, skuSceneImages: { ...generation, rows: reviewedRows }, }, reviewedRows); await syncSelectionFissionPlanWorkflowState(item.planId); return getSelectionFissionPlanDetail(item.planId); } function isSelectionWorkflowTaskStepComplete(task, step) { const skuRows = Array.isArray(task.skuSnapshot?.rows) ? task.skuSnapshot.rows : []; const selectedSkuRows = getWorkflowSelectedSkuRows(task, skuRows); if (step === 3) { const rows = Array.isArray(task.mainImageGeneration?.rows) ? task.mainImageGeneration.rows : []; const requiredCount = getConfiguredMainImageCount(task, rows.length); return rows.length > 0 ? rows.length === requiredCount && rows.every((row) => ( row.imageUrl && (!row.qualityReviewRequired || row.qualityReviewStatus === 'approved') )) : Boolean(task.replacedImageUrl); } if (step === 4) { const selectedSkuCodes = getWorkflowSelectedSkuCodes(task) .filter((skuCode) => skuRows.some((row) => ( normalizeSkuCode(row.skuCode) === normalizeSkuCode(skuCode) && getSkuSelectionImage(task, row.skuCode) ))); return selectedSkuCodes.length > 0 && selectedSkuCodes.every((skuCode) => getVerifiedSkuImageRow(task, skuCode)); } if (step === 5) return true; if (step === 6) { const nameRows = Array.isArray(task.skuNameCandidates?.rows) ? task.skuNameCandidates.rows : []; const selectedCodes = new Set(selectedSkuRows.map((row) => row.skuCode)); const selectedNameRows = nameRows.filter((row) => selectedCodes.has(row.skuCode)); return selectedSkuRows.length > 0 && selectedNameRows.length === selectedSkuRows.length && selectedNameRows.every((row) => ( Array.isArray(row.names) && row.names.length === 5 && row.selectedName )); } if (step === 7) { const forbiddenBrandTerms = getTaskForbiddenBrandTerms(task); return Array.isArray(task.optimizedTitleCandidates) && task.optimizedTitleCandidates.length === 5 && Boolean(task.selectedProductTitle) && !findForbiddenTitleBrandTerm(task.selectedProductTitle, forbiddenBrandTerms); } if (step === 8) { const pricingRows = filterWorkflowRowsBySelectedSkus(task, task.pricingRows); const activityPricingRows = filterWorkflowRowsBySelectedSkus(task, task.activityPricingRows); const isAlibaba1688Pending = getSelectionWorkflowProfile(task) === ALIBABA_1688_PENDING_PROFILE; const pricingComplete = selectedSkuRows.length > 0 && pricingRows.length === selectedSkuRows.length && (isAlibaba1688Pending || activityPricingRows.length === selectedSkuRows.length) && (!isAlibaba1688Pending || pricingRows.every((row) => Number(row.groupPrice) > 0)) && Boolean(task.pricingTableSavedAt); return pricingComplete; } return false; } async function generateWorkflowSkuNames(task) { const skuRows = getWorkflowSelectedSkuRows(task); if (skuRows.length === 0) { const error = new Error('请先选择需要自动上架的SKU'); error.status = 400; throw error; } const generated = await generateSkuNameCandidates({ spuCode: task.selectedSpuCode, spuName: task.selectedSpuName, marketTitle: task.title, }, skuRows.map((row) => ({ skuCode: row.skuCode, originalSkuName: row.skuName || row.productName, colorName: row.colorName, imageUrl: getVerifiedSkuImageRow(task, row.skuCode)?.generatedImageUrl || row.skuImageUrl, }))); const previousSelections = new Map( (Array.isArray(task.skuNameCandidates?.rows) ? task.skuNameCandidates.rows : []) .filter((row) => row.skuCode && row.selectedName) .map((row) => [row.skuCode, row.selectedName]) ); generated.rows = generated.rows.map((row) => ({ ...row, selectedName: resolvePreservedSkuNameSelection(row, previousSelections.get(row.skuCode)), })); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_name_candidates_json = ?, workflow_step = 6, workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [safeJsonStringify(generated), task.id] ); } function extractReferenceBrandTerms(referenceRows = [], options = {}) { const ownBrand = normalizeText(options.ownBrand, 40).replace(/\s+/g, '').toLowerCase(); const genericTerms = new Set([ '新款', '新品', '爆款', '现货', '批发', '家用', '商用', '简约', '现代', '官方', '正品', ]); const categoryAnchors = Array.from(new Set([ ...(Array.isArray(options.categoryTerms) ? options.categoryTerms : []), '电脑桌', '书桌', '餐桌', '茶几', '办公桌', '收纳柜', '书柜', '衣柜', '鞋柜', '沙发', '床垫', '床', '桌', '椅', '柜', '架', ].map((term) => normalizeText(term, 40).replace(/类$/u, '')).filter((term) => term.length >= 1))) .sort((left, right) => right.length - left.length); const candidates = []; const addCandidate = (value, explicit = false) => { const term = normalizeText(value, 40) .replace(/^[【\[((]\s*|\s*[】\]))]$/gu, '') .replace(/[®™]/g, '') .trim(); const key = term.replace(/\s+/g, '').toLowerCase(); if (term.length < 2 || term.length > 16 || !key || key === ownBrand || genericTerms.has(term)) return; candidates.push({ term, key, explicit }); }; for (const row of Array.isArray(referenceRows) ? referenceRows : []) { const metadata = row?.metadata && typeof row.metadata === 'object' ? row.metadata : {}; addCandidate( row?.brandName || row?.brand_name || row?.brand || metadata.brandName || metadata.brand_name || metadata.brand, true ); const title = normalizeText(row?.title, 500).replace(/\s+/g, ''); if (!title) continue; const bracketed = title.match(/^[【\[((]([^】\]))]{2,16})[】\]))]/u)?.[1]; if (bracketed) addCandidate(bracketed, true); const mixedBrandModel = title.match(/^([\p{Script=Han}]{1,6}[A-Za-z][A-Za-z0-9-]{0,8})/u)?.[1] || title.match(/^([A-Za-z][A-Za-z0-9-]{1,15})/u)?.[1]; if (mixedBrandModel) addCandidate(mixedBrandModel, true); const anchor = categoryAnchors .map((term) => ({ term, index: title.indexOf(term) })) .filter(({ index }) => index > 0) .sort((left, right) => left.index - right.index || right.term.length - left.term.length)[0]; if (anchor) addCandidate(title.slice(0, anchor.index)); } const counts = new Map(); for (const candidate of candidates) { const current = counts.get(candidate.key) || { term: candidate.term, count: 0, explicit: false }; current.count += 1; current.explicit = current.explicit || candidate.explicit; counts.set(candidate.key, current); } return Array.from(counts.values()) .filter((candidate) => candidate.explicit || candidate.count >= 2) .map((candidate) => candidate.term) .slice(0, 20); } function getTaskForbiddenBrandTerms(task = {}) { const currentReferenceMode = normalizeAlibaba1688MainImageMode( task.mainImageGeneration?.mode, 'source_original' ); const usesMarketReference = currentReferenceMode === 'market_composite'; const strategyMatchesCurrentMode = normalizeText( task.titleKeywordStrategy?.referenceMode, 32 ) === currentReferenceMode; const selectedReferences = usesMarketReference && Array.isArray(task.selectedMarketReferences) ? task.selectedMarketReferences.filter((reference) => ( !Number.isFinite(Number(reference?.categoryMatchLevel)) || Number(reference.categoryMatchLevel) > 0 )) : []; return Array.from(new Set([ ...(strategyMatchesCurrentMode && Array.isArray(task.titleKeywordStrategy?.forbiddenBrandTerms) ? task.titleKeywordStrategy.forbiddenBrandTerms : []), ...extractReferenceBrandTerms([ ...selectedReferences, ], { categoryTerms: [task.categoryLevel3, task.categoryLevel2, task.categoryLevel1], ownBrand: task.brandName || task.selectedSpuPayload?.brandName || task.selectedSpuPayload?.brand_name, }), ].map((term) => normalizeText(term, 40)).filter(Boolean))).slice(0, 20); } async function loadTitleKeywordSources(task = {}) { const categoryName = normalizeText( task.categoryLevel3 || task.categoryLevel2 || task.categoryLevel1, 255 ); const usesMarketReference = normalizeAlibaba1688MainImageMode( task.mainImageGeneration?.mode, 'source_original' ) === 'market_composite'; const selectedReferences = usesMarketReference && Array.isArray(task.selectedMarketReferences) ? task.selectedMarketReferences.filter((reference) => ( !Number.isFinite(Number(reference?.categoryMatchLevel)) || Number(reference.categoryMatchLevel) > 0 )) : []; let rankedRows = []; if (categoryName) { try { rankedRows = await planningDatabase.query( `SELECT title, search_keyword AS searchKeyword FROM taobao_product_records WHERE title IS NOT NULL AND title <> '' AND ( category_level3 = ? OR market_common_category_name LIKE ? OR standard_product_type LIKE ? ) ORDER BY CASE WHEN industry_rank REGEXP '^[0-9]+$' THEN CAST(industry_rank AS UNSIGNED) ELSE 999999999 END ASC, CAST(COALESCE(NULLIF(analysis_sales, ''), NULLIF(total_sold, ''), '0') AS UNSIGNED) DESC, updated_at DESC LIMIT 20`, [categoryName, `%${categoryName}%`, `%${categoryName}%`] ); } catch (error) { console.warn('读取taobao_product_records标题关键词来源失败:', error.message); } } const competitorTitles = Array.from(new Set( [...selectedReferences, ...rankedRows, { title: task.title }] .map((row) => normalizeText(row?.title, 500)) .filter(Boolean) )).slice(0, 20); const frequency = new Map(); for (const row of rankedRows) { for (const keyword of String(row.searchKeyword || '').split(/[,,、/|\s]+/)) { const normalized = normalizeText(keyword, 40); if (normalized) frequency.set(normalized, (frequency.get(normalized) || 0) + 1); } } const categoryKeywords = Array.from(new Set([ categoryName, ...Array.from(frequency.entries()) .sort((left, right) => right[1] - left[1]) .map(([keyword]) => keyword), task.categoryLevel2, task.categoryLevel1, ].map((value) => normalizeText(value, 40)).filter(Boolean))).slice(0, 10); const forbiddenBrandTerms = getTaskForbiddenBrandTerms({ ...task, selectedMarketReferences: selectedReferences, marketReferenceBatch: { candidates: [] }, }); return { categoryName, competitorTitles, categoryKeywords, forbiddenBrandTerms }; } function collectTitleStrategyKeywords(strategy = {}) { const groups = [ ['品类', [strategy.category]], ['材质', [strategy.material]], ['功能卖点', strategy.functionalSellingPoints], ['场景', strategy.scenes], ['热搜词', strategy.hotKeywords], ['飙升词', strategy.surgingKeywords], ['修饰词', strategy.modifiers], ['类目词', strategy.categoryKeywords], ]; const seen = new Set(); return groups.flatMap(([group, values]) => ( (Array.isArray(values) ? values : []) .map((value) => normalizeText(value, 60)) .filter((keyword) => { const key = keyword.replace(/\s+/g, '').toLowerCase(); if (!key || seen.has(key)) return false; seen.add(key); return true; }) .map((keyword) => ({ group, keyword })) )); } function matchTitleKeywordEvidence(keyword, demandRows = []) { const keywordKey = normalizeText(keyword, 60).replace(/\s+/g, '').toLowerCase(); if (!keywordKey) return null; const ranked = demandRows .map((row) => { const candidate = normalizeText(row.searchKeyword || row.rootWord, 120); const candidateKey = candidate.replace(/\s+/g, '').toLowerCase(); let score = 0; if (candidateKey === keywordKey) score = 3; else if (keywordKey.length >= 2 && candidateKey.includes(keywordKey)) score = 2; else if (candidateKey.length >= 2 && keywordKey.includes(candidateKey)) score = 1; return { row, candidate, score }; }) .filter((entry) => entry.score > 0) .sort((left, right) => ( right.score - left.score || Number(right.row.searchIndex || 0) - Number(left.row.searchIndex || 0) )); return ranked[0] || null; } async function enrichTitleKeywordStrategyWithEvidence(strategy = {}, task = {}) { const keywords = collectTitleStrategyKeywords(strategy); if (keywords.length === 0) return strategy; let result = null; try { const effectiveCategoryLevel3 = task.categoryLevel3 || strategy.category || ''; const commonQuery = { platform: 'TX', category: task.categoryLevel1 || '住宅家具', oneCategory: task.categoryLevel1 || '住宅家具', twoCategory: task.categoryLevel2 || '', threeCategory: effectiveCategoryLevel3, mode: 'trend', dateShortcut: '30d', trendDays: 30, pageSize: 300, }; const targetedResult = await getPlanningDemandKeywordEvidence({ ...commonQuery, keywords: keywords.map((item) => item.keyword), }); result = { demand: { sourceMeta: { source: 'planning_category_demand_indices' }, tableRows: Array.isArray(targetedResult?.rows) ? targetedResult.rows : [], header: { statRangeLabel: targetedResult?.statRangeLabel || '', latestStatDateKey: targetedResult?.latestStatDateKey || '', }, }, }; } catch (error) { console.warn('读取商品标题关键词趋势证据失败:', error.message); } const demandRows = Array.isArray(result?.demand?.tableRows) ? result.demand.tableRows : []; const evidence = keywords.map(({ group, keyword }) => { const matched = matchTitleKeywordEvidence(keyword, demandRows); const row = matched?.row; return { group, keyword, matchedKeyword: matched?.candidate || '', evidenceStatus: row ? 'matched' : 'missing', searchIndex: row?.searchIndex ?? null, searchIndexDisplay: row?.searchIndexDisplay || '--', growth: row?.trendGrowth ?? row?.searchIndexGrow ?? null, growthDisplay: row?.trendGrowthDisplay || row?.searchIndexGrowDisplay || '趋势待采集', trendStatus: row?.trendStatus || 'neutral', trendQuality: row?.trendQuality || 'insufficient_coverage', trendSeries: Array.isArray(row?.trendSeries) ? row.trendSeries.map((point) => ({ dateKey: point.dateKey, value: point.value })) : [], }; }); return { ...strategy, keywordEvidence: evidence, keywordEvidenceMeta: { source: result?.demand?.sourceMeta?.source || 'planning_category_demand_indices', statRangeLabel: result?.demand?.header?.statRangeLabel || '', latestStatDateKey: result?.demand?.header?.latestStatDateKey || '', matchedCount: evidence.filter((row) => row.evidenceStatus === 'matched').length, totalCount: evidence.length, }, }; } async function generateWorkflowProductTitle(task) { const { categoryName, competitorTitles, categoryKeywords, forbiddenBrandTerms, } = await loadTitleKeywordSources(task); const referenceMode = normalizeAlibaba1688MainImageMode( task.mainImageGeneration?.mode, 'source_original' ); const storedStrategyMatchesCurrentMode = normalizeText( task.titleKeywordStrategy?.referenceMode, 32 ) === referenceMode; const keywordStrategy = storedStrategyMatchesCurrentMode ? task.titleKeywordStrategy : { category: categoryName, functionalSellingPoints: [categoryName || task.selectedSpuName].filter(Boolean), scenes: ['家用'], hotKeywords: [categoryName || task.selectedSpuName].filter(Boolean), surgingKeywords: [categoryName || task.selectedSpuName].filter(Boolean), categoryKeywords, competitorTitles, forbiddenBrandTerms, }; const generated = await generateProductTitleCandidates({ marketTitle: task.title, spuCode: task.selectedSpuCode, spuName: task.selectedSpuName, categoryName, imageUrl: task.replacedImageUrl || task.mainImageUrl, competitorTitles, forbiddenBrandTerms, keywordStrategy: { ...keywordStrategy, forbiddenBrandTerms: Array.from(new Set([ ...(keywordStrategy.forbiddenBrandTerms || []), ...forbiddenBrandTerms, ])), }, }); generated.titles = validateAdditionalProductTitleCandidates(task, generated.titles); generated.strategy = { ...(await enrichTitleKeywordStrategyWithEvidence(generated.strategy, task)), referenceMode, }; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET optimized_title_candidates_json = ?, title_keyword_strategy_json = ?, selected_product_title = ?, title_ai_model = ?, title_ai_generated_at = ?, workflow_step = 7, workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(generated.titles), safeJsonStringify(generated.strategy), null, generated.model, new Date(generated.generatedAt), task.id, ] ); } async function processSelectionWorkflowTaskStep(planId, task, step, user) { if (isSelectionWorkflowTaskStepComplete(task, step)) return; await updateItemWorkflowState(task.id, { status: 'running', step }); if (step === 3) { await generateWorkflowMainImage(task); } else if (step === 4) { await generateWorkflowSkuImages(task); } else if (step === 6) { await generateWorkflowSkuNames(task); } else if (step === 7) { await generateWorkflowProductTitle(task); } else if (step === 8) { await generateSelectionFissionPricingTable(planId, task.id, { generateImages: false, refreshSkus: false, }, user); await updateItemWorkflowState(task.id, { status: 'waiting', step: 8 }); } } async function processRemainingSelectionWorkflow(planId, user = {}) { const plan = await getSelectionFissionPlanDetail(planId); const tasks = plan.listingTasks; if (tasks.length === 0 || tasks.some((task) => !task.selectedMainSkuImageUrl)) { await updatePlanWorkflowState(planId, { status: 'waiting', step: 3, progress: 30, }); return; } const runnableTasks = tasks.filter((task) => { const state = getSelectionFissionItemNodeState(task); if (state.completed || state.requiresUser) return false; if (state.step === 4) { return Array.isArray(task.skuSceneImages?.selectedSkuCodes) && task.skuSceneImages.selectedSkuCodes.length > 0; } if (state.step === 6) { const rows = Array.isArray(task.skuNameCandidates?.rows) ? task.skuNameCandidates.rows : []; return rows.length === 0; } if (state.step === 7) { return !Array.isArray(task.optimizedTitleCandidates) || task.optimizedTitleCandidates.length !== 5; } if (state.step === 8) { return task.pricingRows.length === 0 || (getSelectionWorkflowProfile(task) !== ALIBABA_1688_PENDING_PROFILE && task.activityPricingRows.length === 0); } return true; }); for (const task of runnableTasks) { const state = getSelectionFissionItemNodeState(task); await processSelectionWorkflowTaskStep(planId, task, state.step, user); } await syncSelectionFissionPlanWorkflowState(planId); } function getSelectionFissionItemNodeState(item = {}) { const isTask = Boolean(item.selectedSpuCode); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const whiteBackgroundRows = Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []; if (!isTask) { if (!item.mainImageUrl) return { step: 1, action: 'initialize-source' }; if (!Array.isArray(item.similarResponse?.spuCandidates) || item.similarResponse.spuCandidates.length === 0) { return { step: 2, action: 'match-spu' }; } return { step: 2, action: 'select-spu', requiresUser: true }; } if (isAlibaba1688Pending) { if (!isSelectionWorkflowTaskStepComplete(item, 8)) { return { step: 8, action: getWorkflowStepDefinition(8)?.key || 'pricing' }; } for (const step of [3, 4, 6, 7]) { if (!isSelectionWorkflowTaskStepComplete(item, step)) { return { step, action: getWorkflowStepDefinition(step)?.key || `step-${step}` }; } } return { step: 7, action: 'completed', completed: true }; } if (whiteBackgroundRows.length === 0) { return { step: 3, action: 'prepare-main-sku' }; } if (!item.selectedMainSkuImageUrl) { return { step: 3, action: 'select-main-sku', requiresUser: true }; } for (const step of COMPANY_TASK_WORKFLOW_STEPS) { if (!isSelectionWorkflowTaskStepComplete(item, step)) { return { step, action: getWorkflowStepDefinition(step)?.key || `step-${step}` }; } } return { step: 7, action: 'completed', completed: true }; } function getSelectionFissionItemProgress(item = {}) { const state = getSelectionFissionItemNodeState(item); if (!item.selectedSpuCode) { return state.step === 1 ? 0 : (state.requiresUser ? 20 : 12); } if (state.completed) return 100; if (getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE) { const progressByStep = { 8: 8, 3: 40, 4: 58, 6: 74, 7: 88, }; return progressByStep[state.step] || 8; } const companyProgressByStep = { 8: 30, 3: 42, 4: 58, 6: 74, 7: 88 }; return companyProgressByStep[state.step] || 20; } async function syncSelectionFissionPlanWorkflowState(planId, existingPlan = null) { const plan = existingPlan || await getSelectionFissionPlanDetail(planId); const allItems = Array.from(new Map( [...plan.sourceItems, ...plan.listingTasks].map((item) => [item.id, item]) ).values()); const failedItem = allItems.find((item) => item.workflowStatus === 'failed'); const running = allItems.some((item) => item.workflowStatus === 'running'); const everySourceHasTask = plan.sourceItems.length > 0 && plan.sourceItems.every((source) => ( plan.listingTasks.some((task) => task.sourceItemId === source.sourceItemId) )); const tasksCompleted = everySourceHasTask && plan.listingTasks.length > 0 && plan.listingTasks.every((task) => getSelectionFissionItemNodeState(task).completed); const progress = allItems.length > 0 ? allItems.reduce((sum, item) => sum + getSelectionFissionItemProgress(item), 0) / allItems.length : 0; const activeSteps = allItems .map((item) => getSelectionFissionItemNodeState(item).step) .filter(Boolean); await updatePlanWorkflowState(planId, { status: failedItem ? 'failed' : (tasksCompleted ? 'completed' : (running ? 'running' : 'waiting')), step: failedItem ? Math.max(1, Number(failedItem.workflowStep) || 1) : (tasksCompleted ? 7 : activeSteps .slice() .sort((left, right) => getWorkflowStepRank(left) - getWorkflowStepRank(right))[0] || 1), progress: tasksCompleted ? 100 : Math.min(99, progress), errorMessage: failedItem?.workflowErrorMessage || '', }); } function logSelectionFissionPhase(planItemId, phase, startedAt, extra = '') { console.log(`[SelectionFissionTiming] item=${planItemId} phase=${phase} ms=${Date.now() - startedAt}${extra ? ` ${extra}` : ''}`); } async function processSelectionFissionItemNode(planId, planItemId, step, user = {}) { let item = await getPlanItem(planId, planItemId); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const state = getSelectionFissionItemNodeState(item); const targetStep = normalizeWorkflowStep( Math.min(Math.max(Number(step) || state.step, 1), 8) ); if (!item.selectedSpuCode) { if (targetStep === 1) { await refreshPlanItemMarketProfile(item.id, user); await updateItemWorkflowState(item.id, { status: 'completed', step: 1 }); return; } if (targetStep !== 2) { const error = new Error('市场源ID当前只能执行视觉采集或公司SPU匹配'); error.status = 400; throw error; } const step2StartedAt = Date.now(); if (!item.mainImageUrl) { const profileStartedAt = Date.now(); await refreshPlanItemMarketProfile(item.id, user); logSelectionFissionPhase(planItemId, 'market_profile_refresh', profileStartedAt); item = await getPlanItem(planId, planItemId); } if (!item.mainImageUrl) { const error = new Error('该市场ID尚未取得主图,无法执行相似款匹配'); error.status = 400; throw error; } const similarStartedAt = Date.now(); await searchSelectionFissionSimilarProducts(planId, item.id, { imageUrl: item.mainImageUrl, topK: 100, includeRaw: true, }, user); logSelectionFissionPhase(planItemId, 'company_spu_match', similarStartedAt); if (!Array.isArray(item.marketReferenceBatch?.candidates) || item.marketReferenceBatch.candidates.length === 0) { const preloadStartedAt = Date.now(); try { await searchSelectionFissionMarketReferences(planId, item.id, { imageUrl: item.mainImageUrl, batchNo: 1, }, user); logSelectionFissionPhase(planItemId, 'market_reference_preload', preloadStartedAt); } catch (error) { logSelectionFissionPhase(planItemId, 'market_reference_preload_failed', preloadStartedAt); console.warn('[SelectionFissionItemWorkflow] 市场参考款预加载失败,暂不阻断公司SPU选择:', error.message || error); } } await updateItemWorkflowState(item.id, { status: 'waiting', step: 2 }); logSelectionFissionPhase(planItemId, 'step2_total', step2StartedAt); return; } if (targetStep === 8) { await generateSelectionFissionPricingTable(planId, item.id, { generateImages: false, refreshSkus: false, }, user); await updateItemWorkflowState(item.id, { status: 'waiting', step: 8 }); return; } if (targetStep === 3) { const whiteBackgroundRows = Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []; if (whiteBackgroundRows.length === 0) { let skuSnapshot; let whiteBackgroundAssets; if (isAlibaba1688Pending) { skuSnapshot = await loadAndStoreItemSkus(planId, item.id, item.selectedSpuCode); whiteBackgroundAssets = buildSkuSelectionAssetsFromSnapshot(skuSnapshot); } else { const companyAssets = await loadCompanySkuSelectionAssets( planId, item.id, item.selectedSpuCode ); skuSnapshot = companyAssets.snapshot; whiteBackgroundAssets = companyAssets.assets; } await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_snapshot_json = ?, white_background_assets_json = ?, workflow_status = 'waiting', workflow_step = 3, workflow_error_message = ?, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(skuSnapshot), safeJsonStringify(whiteBackgroundAssets), whiteBackgroundAssets.rows.some((row) => row.previewImage?.previewUrl) ? null : isAlibaba1688Pending ? '1688商品数据未返回可用的SKU图片' : '公司SKU接口未返回可选择的SKU图片', item.id, ] ); return; } if (!isAlibaba1688Pending && !item.selectedMainSkuImageUrl) { // 白底图已读取、等待用户选择主图 SKU 的幂等重入:不再报错置为 failed, // 否则前端数据被列表瘦身覆盖等场景下重复触发会让已读取的结果看起来"消失"。 await updateItemWorkflowState(item.id, { status: 'waiting', step: 3, errorMessage: '' }); return; } if (!isSelectionWorkflowTaskStepComplete(item, 8)) { const error = new Error(isAlibaba1688Pending ? '请先完成“1688拼多多代发测算”并保存定价' : '请先完成“全渠道价格策略编排”并保存定价'); error.status = 409; throw error; } await generateWorkflowMainImage(item); if (isAlibaba1688Pending) { await recordCurrentSelectionFissionRevision({ planId, planItemId: item.id, revisionType: 'main_images', summary: `生成或更新${getConfiguredMainImageCount(item, 1)}张商品主图`, user, }); } return; } const previousStep = isAlibaba1688Pending ? getPreviousWorkflowStep(targetStep) : getPreviousCompanyTaskWorkflowStep(targetStep); if (!isSelectionWorkflowTaskStepComplete(item, previousStep)) { const error = new Error(`请先完成“${getWorkflowStepDefinition(previousStep)?.label || '上一节点'}”`); error.status = 409; throw error; } if (targetStep === 4) { await generateWorkflowSkuImages(item); if (isAlibaba1688Pending) { await recordCurrentSelectionFissionRevision({ planId, planItemId: item.id, revisionType: 'sku_images', summary: '生成或更新已选SKU图片', user, }); } } else if (targetStep === 6) { await generateWorkflowSkuNames(item); if (isAlibaba1688Pending) { await recordCurrentSelectionFissionRevision({ planId, planItemId: item.id, revisionType: 'sku_name_candidates', summary: '生成SKU名称候选', user, }); } } else if (targetStep === 7) { await generateWorkflowProductTitle(item); if (isAlibaba1688Pending) { await recordCurrentSelectionFissionRevision({ planId, planItemId: item.id, revisionType: 'title_candidates', summary: '生成商品标题候选', user, }); } } else if (targetStep === 8) { await generateSelectionFissionPricingTable(planId, item.id, { generateImages: false, refreshSkus: false, }, user); await updateItemWorkflowState(item.id, { status: 'waiting', step: 8 }); } } function enqueueSelectionFissionItemNode(planId, planItemId, step, user = {}) { const normalizedPlanId = normalizePositiveInteger(planId, 0); const normalizedItemId = normalizePositiveInteger(planItemId, 0); const jobKey = `${normalizedPlanId}:${normalizedItemId}`; if (!normalizedPlanId || !normalizedItemId || activeItemWorkflowJobs.has(jobKey)) return false; const actor = { id: getActorId(user), name: getDingtalkUserName(user), username: normalizeText(user.username || user.userName || user.email, 120), departmentName: getDepartmentName(user), }; const promise = Promise.resolve() .then(() => processSelectionFissionItemNode(normalizedPlanId, normalizedItemId, step, actor)) .catch(async (error) => { if (error?.code === 'SKU_IMAGE_RUN_SUPERSEDED') { console.info(`[SelectionFissionItemWorkflow] ${error.message} item=${normalizedItemId}`); return; } await updateItemWorkflowState(normalizedItemId, { status: 'failed', step, errorMessage: error.message || '当前节点处理失败', }).catch(() => {}); console.error('[SelectionFissionItemWorkflow] 节点执行失败:', error); }) .finally(async () => { activeItemWorkflowJobs.delete(jobKey); await syncSelectionFissionPlanWorkflowState(normalizedPlanId).catch(() => {}); const pending = pendingItemWorkflowJobs.get(jobKey); if (pending) { pendingItemWorkflowJobs.delete(jobKey); enqueueSelectionFissionItemNode( normalizedPlanId, normalizedItemId, pending.step, pending.user ); } }); activeItemWorkflowJobs.set(jobKey, promise); return true; } async function startSelectionFissionItemWorkflow(planId, planItemId, input = {}, user = {}) { let item = await getPlanItem(planId, planItemId); let state = getSelectionFissionItemNodeState(item); const step = Math.min(Math.max(Number(input.step) || state.step, 1), 8); if (step === 3) { const requestedModeValue = input.mainImageMode || input.main_image_mode; const hasRequestedMode = Boolean(normalizeText(requestedModeValue, 32)); if (hasRequestedMode) { const mode = normalizeAlibaba1688MainImageMode(requestedModeValue); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const availableSourceImages = isAlibaba1688Pending ? getAlibaba1688SourceImages(item).mainImages : [getMainGenerationSourceImage(item)].filter(Boolean); const availableSourceSet = new Set(availableSourceImages); const requestedSourceImages = (Array.isArray(input.sourceImageUrls || input.source_image_urls) ? (input.sourceImageUrls || input.source_image_urls) : []) .map(normalizeImageUrl) .filter((imageUrl) => imageUrl && availableSourceSet.has(imageUrl)); const requestedCount = normalizePositiveInteger( input.mainImageCount || input.main_image_count, mode === 'market_composite' ? Math.max((item.selectedMarketReferences || []).length, 1) : Math.max(requestedSourceImages.length || availableSourceImages.length, 1), getMaxMainImageCount(item) ); const sourceImageUrls = (requestedSourceImages.length > 0 ? requestedSourceImages : availableSourceImages).slice(0, requestedCount); if (mode !== 'market_composite' && sourceImageUrls.length < 1) { const error = new Error(isAlibaba1688Pending ? '1688源商品没有可用于下一步的主图' : '公司商品没有可用于下一步的目标SKU图'); error.status = 409; error.code = 'ALIBABA_1688_SOURCE_IMAGES_REQUIRED'; throw error; } await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET replaced_image_url = NULL, main_image_generation_json = ?, title_keyword_strategy_json = NULL, optimized_title_candidates_json = NULL, selected_product_title = NULL, title_ai_model = NULL, title_ai_generated_at = NULL, workflow_status = 'waiting', workflow_step = 3, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ mode, requestedCount, sourceImageUrls, total: requestedCount, rows: [], configuredAt: new Date().toISOString(), }), item.id, ] ); item = await getPlanItem(planId, planItemId); state = getSelectionFissionItemNodeState(item); } } const requiredMainImageCount = getConfiguredMainImageCount(item, getMaxMainImageCount(item)); const requestedMainImageRetryIndexes = normalizeMainImageRetryIndexes( input.retryMainImageIndexes || input.retry_main_image_indexes, requiredMainImageCount ); if (step === 3 && requestedMainImageRetryIndexes.length > 0) { const existingRows = Array.isArray(item.mainImageGeneration?.rows) ? item.mainImageGeneration.rows : []; if (existingRows.length !== requiredMainImageCount) { const error = new Error('当前主图生成记录不完整,无法指定图片重生'); error.status = 400; throw error; } const retrySet = new Set(requestedMainImageRetryIndexes); const rows = existingRows.map((row, index) => ( retrySet.has(index + 1) ? { ...row, index: index + 1, imageUrl: '', requestId: '', status: 'waiting', progress: 0, error: '', qualityReviewRequired: requiresMainImageQualityReview(item.mainImageGeneration?.mode), qualityReviewStatus: requiresMainImageQualityReview(item.mainImageGeneration?.mode) ? 'pending' : 'not_required', qualityReviewedAt: '', qualityReviewedBy: '', } : row )); const firstImageUrl = rows.find((row) => row.imageUrl)?.imageUrl || ''; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET replaced_image_url = NULLIF(?, ''), main_image_generation_json = ?, workflow_status = 'waiting', workflow_step = 3, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ firstImageUrl, safeJsonStringify({ ...item.mainImageGeneration, total: requiredMainImageCount, rows, updatedAt: new Date().toISOString(), }), item.id, ] ); item = await getPlanItem(planId, planItemId); state = getSelectionFissionItemNodeState(item); } if (state.completed && !input.force && requestedMainImageRetryIndexes.length === 0) { return { ...(await getSelectionFissionPlanDetail(planId)), itemAlreadyCompleted: true, }; } const normalizedPlanId = normalizePositiveInteger(planId, 0); const jobKey = `${normalizedPlanId}:${item.id}`; if (step === 4) { const requestedRetrySkuCodes = Array.isArray(input.retrySkuCodes || input.retry_sku_codes) ? (input.retrySkuCodes || input.retry_sku_codes) .map((code) => normalizeText(code, 160)) .filter(Boolean) : []; const retrySkuCodes = [...new Set(requestedRetrySkuCodes)]; const requestedSkuImageMode = input.skuImageMode ?? input.sku_image_mode; const generationMode = retrySkuCodes.length > 0 ? normalizeSkuImageMode(item.skuSceneImages?.mode) : normalizeSkuImageMode(requestedSkuImageMode || item.skuSceneImages?.mode); const requestedMarketReference = input.marketReference ?? input.market_reference; const persistedMarketReference = item.skuSceneImages?.marketReference || null; const rawMarketReference = retrySkuCodes.length > 0 ? persistedMarketReference : (requestedMarketReference ?? persistedMarketReference); const marketReference = generationMode === 'market_composite' && rawMarketReference ? { id: normalizeText(rawMarketReference.id || rawMarketReference.marketItemId, 160), marketItemId: normalizeText(rawMarketReference.marketItemId || rawMarketReference.id, 160), title: normalizeText(rawMarketReference.title, 500), imageUrl: normalizeImageUrl(rawMarketReference.imageUrl), detailUrl: normalizeText(rawMarketReference.detailUrl, 2000), } : null; if (generationMode === 'market_composite' && !marketReference?.imageUrl) { const error = new Error('参考市场图生成模式必须选择 1 张市场参考图'); error.status = 400; throw error; } const availableRows = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []; const availableSkuRows = availableRows.filter((row) => getSkuSelectionImage(item, row.skuCode)); const availableSkuCodes = [...new Set(availableSkuRows.map((row) => row.skuCode).filter(Boolean))]; const availableCodes = new Set(availableSkuCodes.map(normalizeSkuCode)); if (availableSkuCodes.length === 0) { const error = new Error('当前商品没有可用于生图的 SKU 素材'); error.status = 400; throw error; } if (retrySkuCodes.some((code) => !availableCodes.has(normalizeSkuCode(code)))) { const error = new Error('重试的 SKU 没有可用素材或不属于当前商品'); error.status = 400; throw error; } const requestedSelection = input.selectedSkuCodes ?? input.selected_sku_codes; const hasRequestedSelection = Array.isArray(requestedSelection); const persistedSelection = getWorkflowSelectedSkuCodes(item) .filter((code) => availableCodes.has(normalizeSkuCode(code))); const selectedSkuCodes = retrySkuCodes.length > 0 ? persistedSelection : hasRequestedSelection ? [...new Set(requestedSelection.map((code) => normalizeText(code, 160)).filter(Boolean))] : persistedSelection.length > 0 ? persistedSelection : availableSkuCodes; if (selectedSkuCodes.length < 1) { const error = new Error('请至少选择 1 个需要处理的 SKU'); error.status = 400; throw error; } if (selectedSkuCodes.some((code) => !availableCodes.has(normalizeSkuCode(code)))) { const error = new Error('所选 SKU 没有可用素材或不属于当前商品'); error.status = 400; throw error; } const selectedSkuRows = selectedSkuCodes .map((skuCode) => availableRows.find((row) => ( normalizeSkuCode(row.skuCode) === normalizeSkuCode(skuCode) ))) .filter(Boolean); const nextTask = { ...item, skuSceneImages: { ...(item.skuSceneImages || {}), mode: generationMode, marketReference, generationRunId: crypto.randomUUID(), }, }; const rows = prepareSkuSceneImageRows(nextTask, selectedSkuRows, retrySkuCodes); const summary = summarizeSkuSceneImageRows(rows); const requestSummary = summarizeSkuSceneImageRequests(rows); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = ?, workflow_status = 'waiting', workflow_step = 4, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ ...summary, ...requestSummary, generationRunId: nextTask.skuSceneImages.generationRunId, mode: generationMode, marketReference, selectedSkuCodes, retrySkuCodes, batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, batchTotal: Math.ceil(requestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), currentBatch: 0, rows, warnings: Array.isArray(item.skuSceneImages?.warnings) ? item.skuSceneImages.warnings : [], confirmedAt: new Date().toISOString(), }), item.id, ] ); item = await getPlanItem(planId, planItemId); if (generationMode === 'source_original' && retrySkuCodes.length === 0) { pendingItemWorkflowJobs.delete(jobKey); const completedRows = finalizeSourceSkuImageRows(rows); const completedSummary = summarizeSkuSceneImageRows(completedRows); const completedRequestSummary = summarizeSkuSceneImageRequests(completedRows); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = ?, workflow_status = 'completed', workflow_step = 4, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify({ ...completedSummary, ...completedRequestSummary, generationRunId: nextTask.skuSceneImages.generationRunId, mode: generationMode, marketReference: null, selectedSkuCodes, retrySkuCodes: [], batchSize: SKU_IMAGE_GENERATION_BATCH_SIZE, batchTotal: Math.ceil(completedRequestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), currentBatch: Math.ceil(completedRequestSummary.requestTotal / SKU_IMAGE_GENERATION_BATCH_SIZE), rows: completedRows, warnings: [], confirmedAt: new Date().toISOString(), generatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), item.id, ] ); await syncVerifiedSkuImagesToPricingTable({ ...item, skuSceneImages: { ...(item.skuSceneImages || {}), mode: generationMode, marketReference: null, rows: completedRows, }, }, completedRows); await syncSelectionFissionPlanWorkflowState(planId); return { ...(await getSelectionFissionPlanDetail(planId)), completedSynchronously: true, startedStep: step, }; } } if (activeItemWorkflowJobs.has(jobKey)) { if (step === 4) { pendingItemWorkflowJobs.set(jobKey, { step, user }); } return { ...(await getSelectionFissionPlanDetail(planId)), itemAlreadyRunning: true, itemQueued: step === 4, }; } await updateItemWorkflowState(item.id, { status: 'running', step, errorMessage: '' }); const started = enqueueSelectionFissionItemNode(planId, item.id, step, user); if (!started) { await updateItemWorkflowState(item.id, { status: item.workflowStatus || 'waiting', step: item.workflowStep || state.step, errorMessage: item.workflowErrorMessage || '', }); return { ...(await getSelectionFissionPlanDetail(planId)), itemAlreadyRunning: true, }; } await syncSelectionFissionPlanWorkflowState(planId); return { ...(await getSelectionFissionPlanDetail(planId)), startedItemId: item.id, startedStep: step, }; } function enqueueSelectionFissionWorkflow(planId, user = {}, mode = 'initial') { const normalizedPlanId = normalizePositiveInteger(planId, 0); if (!normalizedPlanId || activeWorkflowJobs.has(normalizedPlanId)) return false; const actor = { id: getActorId(user), name: getDingtalkUserName(user), username: normalizeText(user.username || user.userName || user.email, 120), departmentName: getDepartmentName(user), }; const promise = Promise.resolve() .then(async () => { if (mode === 'prepare-main-sku') { await processMainSkuPreparation(normalizedPlanId, actor); } else if (mode === 'remaining') { await processRemainingSelectionWorkflow(normalizedPlanId, actor); } else { await processInitialSelectionWorkflow(normalizedPlanId, actor); } }) .catch(async (error) => { await updatePlanWorkflowState(normalizedPlanId, { status: 'failed', step: Number((await getSelectionFissionPlanDetail(normalizedPlanId)).workflowStep || 0), progress: Number((await getSelectionFissionPlanDetail(normalizedPlanId)).workflowProgress || 0), errorMessage: error.message || '裂变工作流执行失败', }).catch(() => {}); console.error('[SelectionFissionWorkflow] 执行失败:', error); }) .finally(() => { activeWorkflowJobs.delete(normalizedPlanId); }); activeWorkflowJobs.set(normalizedPlanId, promise); return true; } async function startSelectionFissionWorkflow(planId, user = {}) { const plan = await getSelectionFissionPlanDetail(planId); let mode = 'initial'; if (plan.workflowStep >= 3) { mode = plan.listingTasks.length > 0 && plan.listingTasks.every((task) => task.selectedMainSkuImageUrl) ? 'remaining' : 'prepare-main-sku'; } const started = enqueueSelectionFissionWorkflow(plan.id, user, mode); if (!started) { return { ...(await getSelectionFissionPlanDetail(plan.id)), alreadyRunning: true, }; } await updatePlanWorkflowState(plan.id, { status: 'running', step: mode === 'initial' ? 1 : 3, progress: mode === 'initial' ? Math.max(plan.workflowProgress, 1) : Math.max(plan.workflowProgress, 22), errorMessage: '', }); return { ...(await getSelectionFissionPlanDetail(plan.id)), started: true, }; } async function selectSelectionFissionMainSku(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; if (!item.selectedSpuCode) { const error = new Error('该SPU任务不存在或尚未确认SPU'); error.status = 400; throw error; } const skuCode = normalizeText(input.skuCode || input.sku_code, 160); const rows = Array.isArray(item.whiteBackgroundAssets?.rows) ? item.whiteBackgroundAssets.rows : []; const selected = rows.find((row) => row.skuCode === skuCode); if (!selected?.previewImage?.previewUrl) { const error = new Error(isAlibaba1688Pending ? '请选择一张可用的1688商品SKU图片' : '请选择一张可用的公司SKU白底图'); error.status = 400; throw error; } await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET selected_main_sku_code = ?, selected_main_sku_name = ?, selected_main_sku_image_url = ?, workflow_status = 'waiting', workflow_step = 3, workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ selected.skuCode, selected.skuName || selected.skuCode, selected.previewImage.previewUrl, item.id, ] ); await syncSelectionFissionPlanWorkflowState(item.planId); return getSelectionFissionPlanDetail(planId); } async function updateSelectionFissionProductTitle(planId, planItemId, input = {}, user = {}) { const item = await getPlanItemWithoutPricing(planId, planItemId); if (input.refreshEvidence === true || input.refresh_evidence === true) { const referenceMode = normalizeAlibaba1688MainImageMode( item.mainImageGeneration?.mode, 'source_original' ); const currentStrategy = getCurrentTitleKeywordStrategy(item) || { category: item.categoryLevel3 || item.categoryLevel2 || item.categoryLevel1, }; const strategy = await enrichTitleKeywordStrategyWithEvidence( currentStrategy, item ); strategy.referenceMode = referenceMode; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET title_keyword_strategy_json = ?, updated_at = NOW() WHERE id = ?`, [safeJsonStringify(strategy), item.id] ); return getSelectionFissionPlanDetail(planId); } if (input.keywordStrategy || input.keyword_strategy || input.regenerate === true) { const referenceMode = normalizeAlibaba1688MainImageMode( item.mainImageGeneration?.mode, 'source_original' ); const requestedKeywordStrategy = input.keywordStrategy || input.keyword_strategy; const storedStrategyMatchesCurrentMode = normalizeText( item.titleKeywordStrategy?.referenceMode, 32 ) === referenceMode; const keywordStrategy = requestedKeywordStrategy || (storedStrategyMatchesCurrentMode ? item.titleKeywordStrategy : {}); const { categoryName, competitorTitles, categoryKeywords, forbiddenBrandTerms, } = await loadTitleKeywordSources(item); const generated = await generateProductTitleCandidates({ marketTitle: item.title, spuCode: item.selectedSpuCode, spuName: item.selectedSpuName, categoryName, imageUrl: item.replacedImageUrl || item.mainImageUrl, competitorTitles, forbiddenBrandTerms, keywordStrategy: { ...keywordStrategy, categoryKeywords: keywordStrategy.categoryKeywords?.length ? keywordStrategy.categoryKeywords : categoryKeywords, competitorTitles, forbiddenBrandTerms: Array.from(new Set([ ...(keywordStrategy.forbiddenBrandTerms || []), ...forbiddenBrandTerms, ])), }, }); generated.titles = validateAdditionalProductTitleCandidates(item, generated.titles); generated.strategy = { ...(await enrichTitleKeywordStrategyWithEvidence(generated.strategy, item)), referenceMode, }; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET title_keyword_strategy_json = ?, optimized_title_candidates_json = ?, selected_product_title = ?, title_ai_model = ?, title_ai_generated_at = ?, workflow_step = GREATEST(workflow_step, 7), workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(generated.strategy), safeJsonStringify(generated.titles), null, generated.model, new Date(generated.generatedAt), item.id, ] ); await syncSelectionFissionPlanWorkflowState(item.planId); await recordCurrentSelectionFissionRevision({ planId: item.planId, planItemId: item.id, revisionType: 'title_candidates', summary: '重新生成商品标题候选', user, }); return getSelectionFissionPlanDetail(planId); } const selectedTitle = normalizeText( input.selectedTitle || input.selected_title || input.title, 500 ); const forbiddenBrandTerm = findForbiddenTitleBrandTerm( selectedTitle, getTaskForbiddenBrandTerms(item) ); if (forbiddenBrandTerm) { const error = new Error(`标题包含参考商品品牌或型号“${forbiddenBrandTerm}”,请重新生成或选择其他标题`); error.status = 400; error.code = 'PRODUCT_TITLE_REFERENCE_BRAND_FORBIDDEN'; throw error; } const allowedTitles = getWorkflowProductTitleOptions(item); if (!selectedTitle || !allowedTitles.includes(selectedTitle)) { const error = new Error('选择的商品标题不在原始标题或当前AI候选标题中'); error.status = 400; throw error; } await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET selected_product_title = ?, updated_at = NOW() WHERE id = ?`, [selectedTitle, item.id] ); const detail = await getSelectionFissionPlanDetail(item.planId); await Promise.all([ syncSelectionFissionPlanWorkflowState(item.planId, detail), recordCurrentSelectionFissionRevision({ planId: item.planId, planItemId: item.id, revisionType: 'product_title', summary: `确认最终商品标题:${selectedTitle}`, user, plan: detail, }), ]); // Workflow sync updates the plan-level stage/progress after the title is saved. // Return a fresh detail so the UI can expose the listing action immediately. return getSelectionFissionPlanDetail(item.planId); } async function resolveSelectionFissionListingShop(plan, input = {}, user = {}) { const listingShopId = normalizeText( input.listingShopId || input.listing_shop_id || input.shopId || input.shop_id, 160 ); const executorSessionId = normalizeText( input.executorSessionId || input.executor_session_id || input.shopSessionId || input.shop_session_id, 64 ); if (!listingShopId) { const error = new Error('请选择目标拼多多店铺'); error.status = 400; throw error; } const execution = await listSelectionFissionExecutionOptions(user); const targetShop = execution.executionShops.find((shop) => shop.shopId === listingShopId); let shop = executorSessionId ? execution.executionSessions.find((session) => ( session.sessionId === executorSessionId && session.shopId === listingShopId )) : targetShop?.sessions.find((session) => session.selectable && session.isSelf); if (!shop && !executorSessionId && targetShop?.availableSessionCount === 1) { shop = targetShop.sessions.find((session) => session.selectable); } if (!targetShop || !shop) { const error = new Error(targetShop?.selectable ? '请选择负责执行上架的在线客户端' : '目标店铺没有可用的在线登录客户端'); error.status = 400; error.code = 'LISTING_EXECUTOR_REQUIRED'; error.data = { targetShop: targetShop || null }; throw error; } if (!shop.clientOnline) { const error = new Error('对应的多多自动平台客户端当前不在线,请先启动并登录桌面客户端'); error.status = 409; error.code = 'DESKTOP_CLIENT_OFFLINE'; error.data = { desktop: execution.desktop, shop }; throw error; } if (!shop.loginValid) { const error = new Error('所选拼多多店铺当前未登录或登录状态已失效,请先在桌面客户端完成登录'); error.status = 409; error.code = 'LISTING_SHOP_LOGIN_REQUIRED'; error.data = { shop }; throw error; } return { shop: { ...shop, clientId: shop.executorClientId, requestedByUserId: Number(getActorId(user) || 0) || null, requestedByUserName: getDingtalkUserName(user), }, credential: null, }; } async function resolveLegacyPddPublishingShop(input = {}) { return resolvePddPublishingShop({ shopId: input.listingShopId || input.listing_shop_id || input.shopId || input.shop_id, shopName: input.listingShopName || input.listing_shop_name || input.shopName || input.shop_name, }); } async function listSelectionFissionPddPublishingShops() { return listPddPublishingShops(); } function assertAlibaba1688PddDraftPlan(plan = {}) { if (plan.workflowProfile !== ALIBABA_1688_PENDING_PROFILE) { const error = new Error('仅1688待上架方案支持服务端拼多多草稿发布'); error.status = 400; error.code = 'PDD_DRAFT_WORKFLOW_UNSUPPORTED'; throw error; } if (plan.workflowStatus !== 'completed' || plan.workflowProgress < 100) { const error = new Error('请先完成1688待上架流程,再保存到拼多多草稿箱'); error.status = 409; throw error; } } function getPddDraftItemConfig(input = {}, planItemId) { const rows = input.pddDraftItems || input.pdd_draft_items || input.items || []; return (Array.isArray(rows) ? rows : []).find((row) => ( Number(row.planItemId || row.plan_item_id || row.itemId || row.item_id) === Number(planItemId) )) || null; } async function getSelectionFissionPddDraftMeta(planId, input = {}, user = {}) { const plan = await getSelectionFissionPlanDetail(planId); assertAlibaba1688PddDraftPlan(plan); const listingContext = await resolveLegacyPddPublishingShop(input); const listingShop = listingContext.shop; const planItemId = normalizePositiveInteger( input.planItemId || input.plan_item_id || input.itemId || input.item_id, 0 ); const item = plan.listingTasks.find((row) => Number(row.id) === Number(planItemId)); if (!item) { const error = new Error('1688上架商品不存在或不属于当前方案'); error.status = 404; throw error; } const metadata = await getPddDraftMetadata(listingShop, input, { credential: listingContext.credential, }); const sourceProduct = getAlibaba1688SourceProduct(item); return { ...metadata, planId: plan.id, planItemId: item.id, source: { productId: item.marketItemId, title: item.selectedProductTitle || item.title, category: normalizeText(sourceProduct.category, 500), attributes: Array.isArray(sourceProduct.attributes) ? sourceProduct.attributes : [], dimensions: getSourceDimensions(item), skuCount: getWorkflowSelectedSkuRows(item).length, }, savedConfig: item.pddDraftConfig ? { ...item.pddDraftConfig, categoryPath: metadata.categoryPath?.length ? metadata.categoryPath : item.pddDraftConfig.categoryPath, } : null, attributePreparation: { progress: item.pddAttributeAiProgress, result: item.pddAttributeAiResult, }, }; } function findPddCategoryName(rows = [], catId) { for (const row of Array.isArray(rows) ? rows : []) { if (Number(row.catId) === Number(catId)) return row.name; const childName = findPddCategoryName(row.children, catId); if (childName) return childName; } return ''; } async function persistPddAttributePreparation(itemId, progress, result = undefined) { const values = [safeJsonStringify(progress)]; let resultSql = ''; if (result !== undefined) { resultSql = ', pdd_attribute_ai_result_json = ?'; values.push(result ? safeJsonStringify(result) : null); } values.push(itemId); await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET pdd_attribute_ai_progress_json = ?${resultSql}, updated_at = NOW() WHERE id = ?`, values ); } async function getSelectionFissionPddAttributePreparation(planId, itemId) { const plan = await getSelectionFissionPlanDetail(planId); assertAlibaba1688PddDraftPlan(plan); const item = plan.listingTasks.find((row) => Number(row.id) === Number(itemId)); if (!item) { const error = new Error('1688上架商品不存在或不属于当前方案'); error.status = 404; throw error; } return { planId: plan.id, planItemId: item.id, progress: item.pddAttributeAiProgress || { status: 'idle', progress: 0 }, result: item.pddAttributeAiResult || null, }; } async function startSelectionFissionPddAttributePreparation(planId, itemId, input = {}, user = {}) { const plan = await getSelectionFissionPlanDetail(planId); assertAlibaba1688PddDraftPlan(plan); const item = plan.listingTasks.find((row) => Number(row.id) === Number(itemId)); if (!item) { const error = new Error('1688上架商品不存在或不属于当前方案'); error.status = 404; throw error; } const catId = normalizePositiveInteger(input.catId || input.cat_id, 0); if (!catId) { const error = new Error('请先选择拼多多叶子类目'); error.status = 400; throw error; } const jobKey = Number(item.id); const activeJob = pddAttributePreparationJobs.get(jobKey); if (activeJob) { if (activeJob.catId !== catId) { const error = new Error('当前商品正在分析另一个拼多多类目,请等待本次分析完成'); error.status = 409; throw error; } return { planId: plan.id, planItemId: item.id, progress: activeJob.progress, reused: true, }; } const startedProgress = { status: 'running', stage: 'starting', progress: 1, message: '正在准备商品图片和拼多多类目属性', startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), catId, listingShopId: '', }; const job = { catId, progress: startedProgress }; pddAttributePreparationJobs.set(jobKey, job); let listingContext; let metadata; try { listingContext = await resolveLegacyPddPublishingShop(input); metadata = await getPddDraftMetadata(listingContext.shop, { ...input, catId }, { credential: listingContext.credential, }); if (!metadata.categoryRule) { const error = new Error('拼多多未返回所选叶子类目的属性规则'); error.status = 409; throw error; } startedProgress.listingShopId = listingContext.shop.shopId; job.progress = startedProgress; await persistPddAttributePreparation(item.id, startedProgress, null); } catch (error) { if (pddAttributePreparationJobs.get(jobKey) === job) { pddAttributePreparationJobs.delete(jobKey); } throw error; } setImmediate(async () => { try { const sourceProduct = getAlibaba1688SourceProduct(item); const categoryPathNames = (metadata.categoryPath || []) .map((pathCatId) => findPddCategoryName(metadata.categoryTree, pathCatId)) .filter(Boolean); await preparePddDraftAttributes({ plan, item, categoryName: categoryPathNames[categoryPathNames.length - 1] || findPddCategoryName(metadata.categoryTree, catId), categoryPathNames, productTitle: item.selectedProductTitle || item.title, sourceCategory: normalizeText(sourceProduct.category, 500), sourceDescription: normalizeText( sourceProduct.description || sourceProduct.descriptionText || sourceProduct.detailText || sourceProduct.sellingPoints, 8000 ), sourceAttributes: Array.isArray(sourceProduct.attributes) ? sourceProduct.attributes : [], categoryRule: metadata.categoryRule, }, { updateProgress: async (nextProgress) => { const result = nextProgress.result; const progress = { ...nextProgress, result: undefined, startedAt: startedProgress.startedAt, updatedAt: new Date().toISOString(), catId, listingShopId: listingContext.shop.shopId, }; job.progress = progress; await persistPddAttributePreparation(item.id, progress, result); }, }); } catch (error) { await persistPddAttributePreparation(item.id, { ...startedProgress, status: 'failed', stage: 'failed', message: normalizeText(error.message || '智能属性生成失败', 1000), errorCode: normalizeText(error.code || 'PDD_ATTRIBUTE_PREPARATION_FAILED', 120), updatedAt: new Date().toISOString(), }).catch((persistError) => { console.error('保存拼多多属性生成失败状态异常:', persistError); }); } finally { if (pddAttributePreparationJobs.get(jobKey) === job) { pddAttributePreparationJobs.delete(jobKey); } } }); return { planId: plan.id, planItemId: item.id, progress: startedProgress, reused: false }; } async function recordServerPddDraftSuccess(plan, item, listingShop, config, result, user = {}) { const callbackRecord = { callbackType: 'success', productId: result.goodsId, goodsCommitId: result.goodsCommitId, taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, dingtalkUserName: plan.dingtalkUserName || getDingtalkUserName(user), listingShopName: listingShop.shopName, workerId: 'maixiang-pdd-open-platform', publishMode: 'pdd_open_platform_draft', receivedAt: new Date().toISOString(), }; await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} SET is_current = 0, updated_at = NOW() WHERE plan_item_id = ? AND is_current = 1`, [item.id] ); await connection.execute( `INSERT INTO ${escapeIdentifier(LISTING_RESULT_TABLE)} ( plan_id, plan_item_id, task_code, product_id, dingtalk_user_name, api_key_name, callback_payload_json, callback_count, is_current, first_received_at, last_received_at ) VALUES (?, ?, ?, ?, ?, 'pdd-open-platform', ?, 1, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE plan_item_id = VALUES(plan_item_id), dingtalk_user_name = VALUES(dingtalk_user_name), api_key_name = VALUES(api_key_name), callback_payload_json = VALUES(callback_payload_json), callback_count = callback_count + 1, is_current = 1, last_received_at = NOW(), updated_at = NOW()`, [ plan.id, item.id, item.taskCode || plan.schemeCode, result.goodsId, callbackRecord.dingtalkUserName, safeJsonStringify(callbackRecord), ] ); await connection.execute( `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( plan_id, plan_item_id, task_code, callback_type, product_id, dingtalk_user_name, worker_id, api_key_name, callback_payload_json, received_at, created_at ) VALUES (?, ?, ?, 'success', ?, ?, 'maixiang-pdd-open-platform', 'pdd-open-platform', ?, NOW(), NOW())`, [ plan.id, item.id, item.taskCode || plan.schemeCode, result.goodsId, callbackRecord.dingtalkUserName, safeJsonStringify(callbackRecord), ] ); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listed_item_id = ?, listing_status = 'listed', listing_error_code = NULL, listing_error_message = NULL, listing_completed_at = NOW(), claim_token_hash = NULL, claimed_by = 'maixiang-pdd-open-platform', claimed_at = NOW(), claim_expires_at = NULL, pdd_draft_commit_id = ?, pdd_draft_config_json = ?, pdd_draft_request_hash = ?, pdd_draft_request_json = ?, pdd_draft_response_json = ?, updated_at = NOW() WHERE id = ?`, [ result.goodsId, result.goodsCommitId, safeJsonStringify(config), result.requestHash, safeJsonStringify(result.request), safeJsonStringify(result.response), item.id, ] ); await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET listed_item_id = ?, updated_at = NOW() WHERE plan_item_id = ?`, [result.goodsId, item.id] ); await connection.execute( `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} SET listed_item_id = ?, updated_at = NOW() WHERE plan_item_id = ?`, [result.goodsId, item.id] ); }); } async function recordServerPddDraftFailure(plan, item, config, error, user = {}) { const errorCode = normalizeText(error.code || 'PDD_DRAFT_PUBLISH_FAILED', 120); const errorMessage = normalizeText(error.message || '保存拼多多草稿失败', 1000); const requestHash = buildDraftFingerprint(plan, item, config); const callbackRecord = { callbackType: 'failure', errorCode, errorMessage, taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, dingtalkUserName: plan.dingtalkUserName || getDingtalkUserName(user), workerId: 'maixiang-pdd-open-platform', publishMode: 'pdd_open_platform_draft', resultUnknown: Boolean(error.resultUnknown), receivedAt: new Date().toISOString(), }; await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_status = 'failed', listing_error_code = ?, listing_error_message = ?, claim_token_hash = NULL, claimed_by = 'maixiang-pdd-open-platform', claim_expires_at = NULL, pdd_draft_config_json = ?, pdd_draft_request_hash = ?, updated_at = NOW() WHERE id = ?`, [errorCode, errorMessage, safeJsonStringify(config), requestHash, item.id] ); await connection.execute( `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( plan_id, plan_item_id, task_code, callback_type, error_code, error_message, dingtalk_user_name, worker_id, api_key_name, callback_payload_json, received_at, created_at ) VALUES (?, ?, ?, 'failure', ?, ?, ?, 'maixiang-pdd-open-platform', 'pdd-open-platform', ?, NOW(), NOW())`, [ plan.id, item.id, item.taskCode || plan.schemeCode, errorCode, errorMessage, callbackRecord.dingtalkUserName, safeJsonStringify(callbackRecord), ] ); }); } async function saveAlibaba1688PddDrafts(plan, input = {}, user = {}) { const listingContext = await resolveLegacyPddPublishingShop(input); const listingShop = listingContext.shop; const readiness = buildDeliveryReadiness(plan); if (!readiness.complete) { const error = new Error('1688上架资料仍有缺失,暂不能保存到拼多多草稿箱'); error.status = 409; error.data = { readiness }; throw error; } const configs = new Map(plan.listingTasks.map((item) => [ Number(item.id), getPddDraftItemConfig(input, item.id), ])); const missingConfigs = plan.listingTasks.filter((item) => ( !item.listedItemId && !configs.get(Number(item.id)) )); if (missingConfigs.length > 0) { const error = new Error(`还有${missingConfigs.length}个1688商品未完成拼多多类目和发布属性确认`); error.status = 400; error.code = 'PDD_DRAFT_CONFIG_REQUIRED'; error.data = { itemIds: missingConfigs.map((item) => item.id) }; throw error; } const unconfirmedAttributes = plan.listingTasks.find((item) => { if (item.listedItemId) return false; const config = configs.get(Number(item.id)); const progress = item.pddAttributeAiProgress || {}; return progress.status !== 'completed' || Number(progress.catId) !== Number(config?.catId) || normalizeText(progress.listingShopId, 160) !== normalizeText(listingShop.shopId, 160); }); if (unconfirmedAttributes) { const error = new Error( `商品 ${unconfirmedAttributes.marketItemId} 尚未完成当前店铺和类目下的OCR与智能属性分析` ); error.status = 409; error.code = 'PDD_ATTRIBUTE_PREPARATION_REQUIRED'; throw error; } const indeterminate = plan.listingTasks.find((item) => ( item.listingErrorCode === 'PDD_DRAFT_RESULT_UNKNOWN' && !item.listedItemId )); if (indeterminate) { const error = new Error( `商品 ${indeterminate.marketItemId} 上次保存草稿时结果未知,需先在拼多多草稿箱核对,禁止直接重复创建` ); error.status = 409; error.code = 'PDD_DRAFT_RESULT_UNKNOWN'; throw error; } await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET listing_shop_id = ?, listing_shop_code = ?, listing_shop_name = ?, listing_shop_platform = ?, listing_shop_snapshot_json = ?, status = 'claimed', auto_listing_error_code = NULL, auto_listing_error_message = NULL, claimed_by = 'maixiang-pdd-open-platform', claimed_at = NOW(), claim_expires_at = NULL, updated_at = NOW() WHERE id = ?`, [ listingShop.shopId, listingShop.shopCode || null, listingShop.shopName, listingShop.platform || 'PDD', safeJsonStringify(listingShop), plan.id, ] ); }); const results = []; for (const item of plan.listingTasks) { const config = configs.get(Number(item.id)); if (item.listedItemId) { results.push({ planItemId: item.id, success: true, skipped: true, goodsId: item.listedItemId, goodsCommitId: item.pddDraftCommitId, }); continue; } const requestHash = buildDraftFingerprint(plan, item, config); const acquireResult = await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_status = 'claimed', claimed_by = 'maixiang-pdd-open-platform', claimed_at = NOW(), claim_expires_at = NULL, pdd_draft_config_json = ?, pdd_draft_request_hash = ?, updated_at = NOW() WHERE id = ? AND NULLIF(TRIM(listed_item_id), '') IS NULL AND listing_status IN ('draft', 'pending', 'failed')`, [safeJsonStringify(config), requestHash, item.id] ); if (Number(acquireResult?.affectedRows || 0) !== 1) { results.push({ planItemId: item.id, marketItemId: item.marketItemId, success: false, errorCode: 'PDD_DRAFT_ALREADY_RUNNING', errorMessage: '该商品已有服务端草稿发布流程正在执行,禁止重复提交', }); continue; } try { const result = await publishPddGoodsDraft(plan, item, config, listingShop, { credential: listingContext.credential, }); await recordServerPddDraftSuccess(plan, item, listingShop, config, result, user); results.push({ planItemId: item.id, marketItemId: item.marketItemId, success: true, goodsId: result.goodsId, goodsCommitId: result.goodsCommitId, uploadedImages: result.uploadedImages, }); } catch (error) { await recordServerPddDraftFailure(plan, item, config, error, user); results.push({ planItemId: item.id, marketItemId: item.marketItemId, success: false, errorCode: error.code || 'PDD_DRAFT_PUBLISH_FAILED', errorMessage: error.message || '保存拼多多草稿失败', resultUnknown: Boolean(error.resultUnknown), }); } } await recomputePlanListedState(plan.id); const updatedPlan = await getSelectionFissionPlanDetail(plan.id); return { ...updatedPlan, publishMode: 'pdd_open_platform_draft', publishResults: results, successCount: results.filter((row) => row.success).length, failureCount: results.filter((row) => !row.success).length, }; } async function saveSelectionFissionAutoListing(planId, input = {}, user = {}) { const plan = await getSelectionFissionPlanDetail(planId); if (plan.workflowStatus !== 'completed' || plan.workflowProgress < 100) { const error = new Error(plan.workflowProfile === ALIBABA_1688_PENDING_PROFILE ? '请先完成1688待上架流程,再选择目标渠道和店铺' : '请先完成全部7个裂变节点,再保存自动上架'); error.status = 409; throw error; } const { shop: listingShop } = await resolveSelectionFissionListingShop(plan, input, user); const readiness = buildDeliveryReadiness(plan); if (!readiness.complete) { const error = new Error('裂变资料仍有缺失,暂不能保存自动上架'); error.status = 409; error.data = { readiness }; throw error; } await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET listing_shop_id = ?, listing_shop_code = ?, listing_shop_name = ?, listing_shop_platform = ?, listing_shop_snapshot_json = ?, status = 'pending', auto_listing_error_code = NULL, auto_listing_error_message = NULL, updated_at = NOW() WHERE id = ?`, [ listingShop.shopId, listingShop.shopCode || null, listingShop.shopName, listingShop.platform || 'PDD', safeJsonStringify(listingShop), plan.id, ] ); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_status = CASE WHEN NULLIF(TRIM(listed_item_id), '') IS NOT NULL THEN 'listed' ELSE 'pending' END, listing_error_code = NULL, listing_error_message = NULL, claim_token_hash = NULL, claim_expires_at = NULL, updated_at = NOW() WHERE plan_id = ? AND NULLIF(TRIM(selected_spu_code), '') IS NOT NULL`, [plan.id] ); }); await recomputePlanListedState(plan.id); return getSelectionFissionPlanDetail(plan.id); } function getRelistableSelectionTasks(plan = {}) { const listingTasks = Array.isArray(plan.listingTasks) ? plan.listingTasks : []; return listingTasks.filter((item) => item.listingStatus === 'listed' || item.listedItemId); } async function requeueSelectionFissionAutoListing(planId) { const plan = await getSelectionFissionPlanDetail(planId); const relistableTasks = getRelistableSelectionTasks(plan); if (relistableTasks.length === 0) { const error = new Error('当前方案没有已上架成功的子任务'); error.status = 409; throw error; } const itemIds = relistableTasks.map((item) => Number(item.id)); const productIds = relistableTasks.map((item) => item.listedItemId).filter(Boolean); const itemPlaceholders = itemIds.map(() => '?').join(', '); const productResultCondition = productIds.length > 0 ? ` OR product_id IN (${productIds.map(() => '?').join(', ')})` : ''; await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} SET is_current = 0, updated_at = NOW() WHERE plan_id = ? AND is_current = 1 AND (plan_item_id IN (${itemPlaceholders})${productResultCondition})`, [plan.id, ...itemIds, ...productIds] ); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listed_item_id = NULL, listing_status = 'pending', claim_token_hash = NULL, claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, listing_error_code = NULL, listing_error_message = NULL, listing_completed_at = NULL, updated_at = NOW() WHERE plan_id = ? AND id IN (${itemPlaceholders}) AND (listing_status = 'listed' OR NULLIF(TRIM(listed_item_id), '') IS NOT NULL)`, [plan.id, ...itemIds] ); await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET listed_item_id = NULL, updated_at = NOW() WHERE plan_item_id IN (${itemPlaceholders})`, itemIds ); await connection.execute( `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} SET listed_item_id = NULL, updated_at = NOW() WHERE plan_item_id IN (${itemPlaceholders})`, itemIds ); await connection.execute( `UPDATE ${escapeIdentifier(PLAN_TABLE)} SET listing_shop_id = NULL, listing_shop_code = NULL, listing_shop_name = NULL, listing_shop_platform = NULL, listing_shop_snapshot_json = NULL, claim_token_hash = NULL, claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, auto_listed_product_id = NULL, auto_listing_completed_at = NULL, auto_listing_failed_at = NULL, auto_listing_error_code = NULL, auto_listing_error_message = NULL, status = 'pending', updated_at = NOW() WHERE id = ?`, [plan.id] ); }); await recomputePlanListedState(plan.id); const detail = await getSelectionFissionPlanDetail(plan.id); return { ...detail, requeuedTaskCount: relistableTasks.length, previousProductIds: productIds, }; } function normalizeFreightProfile(raw = {}) { const profile = raw && typeof raw === 'object' ? raw : {}; const read = (...keys) => { for (const key of keys) { if (profile[key] !== undefined && profile[key] !== null && profile[key] !== '') { return normalizeDecimal(profile[key]); } } return null; }; const minimumPricesBySku = profile.minimumPricesBySku || profile.minimum_prices_by_sku || {}; return { nationalFreight: read('nationalFreight', 'freightNational', 'national_freight', '全国运费'), zone1Freight: read('zone1Freight', 'freightZone1', 'zone1_freight', '一区运费'), zone2Freight: read('zone2Freight', 'freightZone2', 'zone2_freight', '二区运费'), zone3Freight: read('zone3Freight', 'freightZone3', 'zone3_freight', '三区运费'), zone4Freight: read('zone4Freight', 'freightZone4', 'zone4_freight', '四区运费'), aftersaleRatio: read('aftersaleRatio', 'afterSalesRatio', 'companyAftersaleRatio', '公司售后占比'), minimumPricesBySku, raw, }; } async function fetchFreightProfile() { return { ...normalizeFreightProfile({}), warning: '请在测算时填写运费、售后占比和最低限价' }; } function resolveMinimumPrice(sku, freightProfile) { const map = freightProfile?.minimumPricesBySku || {}; const mapped = map?.[sku.skuCode] ?? map?.[String(sku.skuCode || '').toUpperCase()] ?? map?.[String(sku.skuCode || '').toLowerCase()]; return normalizeDecimal(mapped) ?? normalizeNullableDecimal(sku.minimumPrice); } function buildEmptyPddActivityPrices() { return Object.fromEntries(PDD_ACTIVITY_PRICE_DEFINITIONS.map((definition) => [ definition.key, { label: definition.label, referenceRate: definition.referenceRate ?? null, price: null, }, ])); } async function generateSelectionFissionPricingTable(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; if (!item.selectedSpuCode) { const error = new Error(isAlibaba1688Pending ? '缺少1688商品ID,无法生成定价表' : '请先确认公司SPU后再生成定价表'); error.status = 400; throw error; } let skuSnapshot = item.skuSnapshot; if (!skuSnapshot?.rows?.length || Boolean(input.refreshSkus)) { skuSnapshot = await loadAndStoreItemSkus(planId, planItemId, item.selectedSpuCode); } let globalEncoding = null; let globalEncodingState = null; if (isAlibaba1688Pending) { globalEncoding = await loadAlibaba1688GlobalEncoding(item); globalEncodingState = buildAlibaba1688GlobalEncodingState(globalEncoding); skuSnapshot = applyAlibaba1688GlobalEncodingToSnapshot(skuSnapshot, globalEncoding); } const allSkuRows = Array.isArray(skuSnapshot?.rows) ? skuSnapshot.rows : []; const skuRows = getWorkflowSelectedSkuRows(item, allSkuRows); if (skuRows.length === 0) { const error = new Error('未查询到已选SKU明细,请返回SKU节点重新选择'); error.status = 404; throw error; } const freightProfile = isAlibaba1688Pending ? { nationalFreight: skuRows.find((row) => row.freight !== null && row.freight !== undefined)?.freight ?? null, zone1Freight: null, zone2Freight: null, zone3Freight: null, zone4Freight: null, aftersaleRatio: null, minimumPricesBySku: Object.fromEntries( skuRows .filter((row) => row.minimumPrice !== null && row.minimumPrice !== undefined) .map((row) => [row.skuCode, row.minimumPrice]) ), raw: null, } : await fetchFreightProfile({ spuCode: item.selectedSpuCode, skuRows }, user); const generatedImages = new Map( skuRows .map((sku) => [normalizeSkuCode(sku.skuCode), getVerifiedSkuImageRow(item, sku.skuCode)]) .filter(([, row]) => row) .map(([skuCode, row]) => [skuCode, { imageUrl: row.generatedImageUrl, requestId: row.requestId || '', }]) ); const imageWarnings = []; const existingPricingRows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ?`, [item.id] ); const existingPricingBySku = new Map( existingPricingRows.map(mapPricingRow).map((row) => [row.skuCode.toUpperCase(), row]) ); const operatorName = getDingtalkUserName(user); const brandName = normalizeText( item.selectedSpuPayload?.brand || item.selectedSpuPayload?.brandName || item.selectedSpuPayload?.brand_name, 160 ); const operatedAt = new Date(); const workflowSkuNameRows = Array.isArray(item.skuNameCandidates?.rows) ? item.skuNameCandidates.rows : []; const workflowSkuNamesByCode = new Map(workflowSkuNameRows.map((row) => [ normalizeText(row.skuCode, 160).toUpperCase(), Array.isArray(row.names) ? row.names : [], ])); const rows = skuRows.map((sku) => { const generated = generatedImages.get(normalizeSkuCode(sku.skuCode)); const aftersaleRatio = normalizeNullableDecimal( freightProfile.aftersaleRatio ?? sku.aftersaleRatio ); const existing = existingPricingBySku.get(String(sku.skuCode || '').toUpperCase()); const workflowNames = workflowSkuNamesByCode.get(String(sku.skuCode || '').toUpperCase()) || []; return { skuCode: sku.skuCode, originalSkuName: sku.skuName || sku.productName, optimizedSkuNames: workflowNames.length === 5 ? workflowNames : (existing?.optimizedSkuNames || []), selectedSkuName: existing?.selectedSkuName || workflowSkuNameRows.find( (row) => String(row.skuCode || '').toUpperCase() === String(sku.skuCode || '').toUpperCase() )?.selectedName || '', aiSkuNameModel: item.skuNameCandidates?.model || existing?.aiSkuNameModel || '', aiSkuNameGeneratedAt: item.skuNameCandidates?.generatedAt || existing?.aiSkuNameGeneratedAt || null, skuImageUrl: getSkuSelectionImage(item, sku.skuCode) || sku.skuImageUrl, generatedImageUrl: generated?.imageUrl || '', productSource: sku.sourceSystem || (isAlibaba1688Pending ? '1688商品数据' : '公司商品API'), operatorName, operatedAt, brandName, barcode: normalizeText( sku.raw?.barcode || sku.raw?.bar_code || sku.raw?.sku_barcode || sku.skuCode, 160 ), size: sku.size || '', color: sku.colorName || '', cost: normalizeNullableDecimal(sku.cost ?? sku.purchasePrice), competitorPrice: existing?.competitorPrice ?? null, installationFee: existing?.installationFee ?? null, freightNational: normalizeNullableDecimal(freightProfile.nationalFreight), freightZone1: normalizeNullableDecimal(freightProfile.zone1Freight), freightZone2: normalizeNullableDecimal(freightProfile.zone2Freight), freightZone3: normalizeNullableDecimal(freightProfile.zone3Freight), freightZone4: normalizeNullableDecimal(freightProfile.zone4Freight), discountAmount: null, activityDiscount: null, aftersaleRatio, promotionRatio: normalizeNullableDecimal(sku.promotionRatio), grossMargin: existing?.grossMargin ?? null, platformCommissionRatio: existing?.platformCommissionRatio ?? null, marketingAdRatio: existing?.marketingAdRatio ?? null, farmingRatio: existing?.farmingRatio ?? null, insuranceRatio: existing?.insuranceRatio ?? null, brandUsageRatio: existing?.brandUsageRatio ?? null, estimatedFeeRatio: null, finalPrice: null, grossProfit: null, operatingProfit: existing?.operatingProfit ?? null, operatingProfitRatio: existing?.operatingProfitRatio ?? null, minimumPrice: resolveMinimumPrice(sku, freightProfile), internalProductCode: normalizeText(sku.internalProductCode, 160), productCodeProductName: normalizeText(sku.productCodeProductName, 255), productCodeColorSpec: normalizeText(sku.productCodeColorSpec, 500), listedItemId: item.listedItemId, rawPayload: { sku, generatedImage: generated || null, freightProfile, }, }; }); const activityRows = isAlibaba1688Pending ? [] : rows.map((row) => ({ skuCode: row.skuCode, productSource: row.productSource, productImageUrl: row.generatedImageUrl || row.skuImageUrl, operatorName: row.operatorName, brandName: row.brandName, operatedAt: row.operatedAt, barcode: row.barcode, size: row.size, color: row.color, competitorPrice: row.competitorPrice, landedPrice: row.finalPrice, couponAmount: row.discountAmount, activityDiscount: row.activityDiscount, groupPrice: null, finalGroupPrice: null, activityPrices: buildEmptyPddActivityPrices(), rawPayload: { sourceTable: PRICING_TABLE, sourceSkuCode: row.skuCode, template: '定价表-拼多多-江陵.xlsx', }, })); await planningDatabase.withTransaction(async (connection) => { await connection.execute( `DELETE FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ?`, [item.id] ); await connection.execute( `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} WHERE plan_item_id = ?`, [item.id] ); const pricingInsertColumns = [ 'plan_id', 'plan_item_id', 'sku_code', 'sku_name', 'ai_sku_name_candidates_json', 'selected_sku_name', 'ai_sku_name_model', 'ai_sku_name_generated_at', 'sku_image_url', 'generated_image_url', 'product_source', 'operator_name', 'operated_at', 'brand_name', 'barcode', 'size_label', 'color_label', 'cost_amount', 'competitor_price', 'installation_fee', 'freight_national', 'freight_zone1', 'freight_zone2', 'freight_zone3', 'freight_zone4', 'discount_amount', 'activity_discount', 'aftersale_ratio', 'promotion_ratio', 'gross_margin', 'platform_commission_ratio', 'marketing_ad_ratio', 'farming_ratio', 'insurance_ratio', 'brand_usage_ratio', 'estimated_fee_ratio', 'final_price', 'gross_profit', 'operating_profit', 'operating_profit_ratio', 'minimum_price', 'internal_product_code', 'product_code_product_name', 'product_code_color_spec', 'listed_item_id', 'raw_payload_json', ]; const pricingValueRows = rows.map((row) => normalizeSqlBindValues([ item.planId, item.id, row.skuCode, row.originalSkuName || null, safeJsonStringify(row.optimizedSkuNames), row.selectedSkuName || null, row.aiSkuNameModel || null, toMysqlDateTime(row.aiSkuNameGeneratedAt), row.skuImageUrl || null, row.generatedImageUrl || null, row.productSource || null, row.operatorName || null, toMysqlDateTime(row.operatedAt), row.brandName || null, row.barcode || null, row.size || null, row.color || null, row.cost, row.competitorPrice, row.installationFee, row.freightNational, row.freightZone1, row.freightZone2, row.freightZone3, row.freightZone4, row.discountAmount, row.activityDiscount, row.aftersaleRatio, row.promotionRatio, row.grossMargin, row.platformCommissionRatio, row.marketingAdRatio, row.farmingRatio, row.insuranceRatio, row.brandUsageRatio, row.estimatedFeeRatio, row.finalPrice, row.grossProfit, row.operatingProfit, row.operatingProfitRatio, row.minimumPrice, row.internalProductCode || null, row.productCodeProductName || null, row.productCodeColorSpec || null, row.listedItemId || null, safeJsonStringify(row.rawPayload), ])); await insertRowsInBatches( connection, PRICING_TABLE, pricingInsertColumns, pricingValueRows ); const activityInsertColumns = [ 'plan_id', 'plan_item_id', 'sku_code', 'product_source', 'product_image_url', 'operator_name', 'brand_name', 'operated_at', 'barcode', 'size_label', 'color_label', 'competitor_price', 'landed_price', 'coupon_amount', 'activity_discount', 'group_price', 'final_group_price', 'activity_prices_json', 'raw_payload_json', ]; if (activityRows.length > 0) { const activityValueRows = activityRows.map((row) => normalizeSqlBindValues([ item.planId, item.id, row.skuCode, row.productSource || null, row.productImageUrl || null, row.operatorName || null, row.brandName || null, toMysqlDateTime(row.operatedAt), row.barcode || null, row.size || null, row.color || null, row.competitorPrice, row.landedPrice, row.couponAmount, row.activityDiscount, row.groupPrice, row.finalGroupPrice, safeJsonStringify(row.activityPrices), safeJsonStringify(row.rawPayload), ])); await insertRowsInBatches( connection, ACTIVITY_PRICING_TABLE, activityInsertColumns, activityValueRows ); } const globalEncodingSql = isAlibaba1688Pending ? `, product_code_status = ?, product_code_style_code = ?, product_code_request_hash = ?, product_code_config_json = ?, product_code_request_json = ?, product_code_response_json = ?, product_code_error_message = NULL, product_code_requested_at = ?, product_code_completed_at = ?` : ''; await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_snapshot_json = ?, freight_profile_json = ?, pricing_table_saved_at = NULL, pricing_table_saved_by = NULL, workflow_status = 'waiting', workflow_step = 8, workflow_error_message = NULL${globalEncodingSql}, updated_at = NOW() WHERE id = ?`, [ safeJsonStringify(skuSnapshot), safeJsonStringify(freightProfile), ...(isAlibaba1688Pending ? [ globalEncodingState.status, globalEncodingState.styleCode || null, globalEncodingState.requestHash, safeJsonStringify(globalEncodingState.config), safeJsonStringify(globalEncodingState.request), safeJsonStringify(globalEncodingState.response), toMysqlDateTime(globalEncodingState.requestedAt), toMysqlDateTime(globalEncodingState.completedAt), ] : []), item.id, ] ); }); const detail = await getSelectionFissionPlanDetail(item.planId); return { plan: detail, planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, warnings: [ freightProfile.warning, ...imageWarnings, ].filter(Boolean), }; } function normalizeProfitPricingRows(inputRows = []) { if (!Array.isArray(inputRows) || inputRows.length === 0) { const error = new Error('定价表没有SKU数据,无法保存'); error.status = 400; throw error; } const seen = new Set(); return inputRows.map((row, index) => { const skuCode = normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase(); const groupPrice = normalizeDecimal(row.groupPrice ?? row.finalGroupPrice ?? row.price); if (!skuCode) { const error = new Error(`第 ${index + 1} 行缺少SKU编码`); error.status = 400; throw error; } if (seen.has(skuCode)) { const error = new Error(`SKU ${skuCode} 重复,无法保存定价表`); error.status = 400; throw error; } if (groupPrice === null || groupPrice <= 0) { const error = new Error(`SKU ${skuCode} 的拼单价必须大于0`); error.status = 400; throw error; } seen.add(skuCode); return { skuCode, originalSkuName: normalizeText( row.originalSkuName || row.original_sku_name || row.sourceSkuName || row.productName, 255 ), selectedSkuName: normalizeText( row.selectedSkuName || row.selected_sku_name || row.skuName || row.name || row.productName, 255 ), colorName: normalizeText(row.colorName || row.color, 255), colorSpec: normalizeText(row.colorSpec || row.color_spec, 500), lengthCm: normalizeDecimal(row.lengthCm ?? row.length_cm), widthCm: normalizeDecimal(row.widthCm ?? row.width_cm), heightCm: normalizeDecimal(row.heightCm ?? row.height_cm), weight: normalizeDecimal(row.weight ?? row.weightKg ?? row.weight_kg), cost: normalizeNullableDecimal(row.cost), groupPrice, finalPrice: normalizeDecimal(row.price ?? row.finalPrice), brandName: normalizeText(row.brand || row.brandName, 160), installationFee: normalizeDecimal(row.installFee ?? row.installationFee), freightNational: normalizeDecimal(row.shipping ?? row.freightNational), discountAmount: normalizeDecimal(row.coupon ?? row.discountAmount ?? row.couponAmount), activityDiscount: normalizeDecimal(row.activityDiscount), grossMargin: normalizeDecimal(row.marginRate ?? row.grossMargin), platformCommissionRatio: normalizeDecimal(row.commission ?? row.platformCommissionRatio), promotionRatio: normalizeDecimal(row.promoRate ?? row.promotionRatio), marketingAdRatio: normalizeDecimal(row.adFee ?? row.marketingAdRatio), farmingRatio: normalizeDecimal(row.seeding ?? row.farmingRatio), aftersaleRatio: normalizeDecimal(row.afterSale ?? row.aftersaleRatio), insuranceRatio: normalizeDecimal(row.insurance ?? row.insuranceRatio), brandUsageRatio: normalizeDecimal(row.brandFee ?? row.brandUsageRatio), operatingProfit: normalizeDecimal(row.profitAmount ?? row.operatingProfit), operatingProfitRatio: normalizeDecimal(row.targetMargin ?? row.operatingProfitRatio), minimumPrice: normalizeNullableDecimal(row.minimumPrice ?? row.companyPriceLimit), snapshot: row, }; }); } function parseSelectionPriceBand(value) { const numbers = String(value || '') .replace(/,/g, '') .match(/\d+(?:\.\d+)?/g) ?.map(Number) .filter(Number.isFinite) || []; if (numbers.length === 0) return null; if (numbers.length === 1) return { min: numbers[0], max: numbers[0] }; return { min: Math.min(numbers[0], numbers[1]), max: Math.max(numbers[0], numbers[1]) }; } function buildSelectionSupplyDecision(rows = [], metadata = {}, source = 'company') { const marketEvidence = metadata?.marketEvidence && typeof metadata.marketEvidence === 'object' ? metadata.marketEvidence : {}; const priceBand = normalizeText(marketEvidence.priceBand || metadata.priceBand, 120); const parsedPriceBand = parseSelectionPriceBand(priceBand); const evaluatedRows = rows.map((row) => { const price = normalizeNullableDecimal(row.finalPrice ?? row.groupPrice); const cost = normalizeNullableDecimal(row.cost); const shipping = normalizeNullableDecimal(row.freightNational); const installationFee = normalizeNullableDecimal(row.installationFee) ?? 0; const targetMargin = normalizeNullableDecimal(row.operatingProfitRatio); const rates = [ row.platformCommissionRatio, row.marketingAdRatio, row.farmingRatio, row.aftersaleRatio, row.insuranceRatio, row.brandUsageRatio, targetMargin, ].map(normalizeNullableDecimal); const evidenceComplete = price !== null && price > 0 && cost !== null && shipping !== null && rates.every((value) => value !== null); const allowedMaxCost = evidenceComplete ? price - installationFee - shipping - (price * rates.reduce((sum, value) => sum + value, 0)) : null; const withinMarketPrice = !parsedPriceBand || price === null ? null : price >= parsedPriceBand.min && price <= parsedPriceBand.max; const hasProfitRoom = evidenceComplete && normalizeNullableDecimal(row.promotionRatio) !== null ? Number(row.promotionRatio) >= 0 : evidenceComplete ? cost <= allowedMaxCost : null; const passed = evidenceComplete && hasProfitRoom && withinMarketPrice !== false; return { skuCode: row.skuCode, cost, shipping, installationFee, finalPrice: price, minimumPrice: normalizeNullableDecimal(row.minimumPrice), targetMargin, promotionRoomRatio: normalizeNullableDecimal(row.promotionRatio), allowedMaxCost: allowedMaxCost === null ? null : Number(allowedMaxCost.toFixed(2)), withinMarketPrice, evidenceComplete, passed, }; }); const completeRows = evaluatedRows.filter((row) => row.evidenceComplete); const passedRows = completeRows.filter((row) => row.passed); const passRate = completeRows.length > 0 ? passedRows.length / completeRows.length : null; const status = completeRows.length !== evaluatedRows.length || completeRows.length === 0 ? 'evidence_insufficient' : passRate >= 0.8 ? 'advantage' : passRate > 0 ? 'partial_advantage' : 'no_advantage'; return { version: 1, source, status, evaluatedAt: new Date().toISOString(), marketPriceBand: priceBand || null, totalSkuCount: evaluatedRows.length, completeSkuCount: completeRows.length, passedSkuCount: passedRows.length, passRate, rows: evaluatedRows, }; } function buildSelectionSupplyMetadata(metadata = {}, decision = null, source = 'company') { const currentMetadata = metadata && typeof metadata === 'object' ? metadata : {}; if (source !== '1688') { return { ...currentMetadata, supplyDecision: decision }; } return { ...currentMetadata, supplyDecision: decision, supplyComparison: { ...(currentMetadata.supplyComparison && typeof currentMetadata.supplyComparison === 'object' ? currentMetadata.supplyComparison : {}), alibaba1688: decision, finalSource: '1688', comparedAt: new Date().toISOString(), }, }; } const PRODUCT_CODE_CONFIG_FIELDS = [ 'taxStatus', 'invoiceType', 'installationService', 'installationMode', ]; function normalizeProductCodeMeta(input = {}) { const meta = input && typeof input === 'object' ? input : {}; const optionGroups = Array.isArray(meta.productTagOptionGroups) ? meta.productTagOptionGroups : []; return { operationCenters: (Array.isArray(meta.operationCenters) ? meta.operationCenters : []) .map((row) => normalizeText(row?.name || row, 160)) .filter(Boolean), categories: (Array.isArray(meta.categories) ? meta.categories : []) .map((row) => normalizeText(row?.name || row, 255)) .filter(Boolean), tagOptions: Object.fromEntries(optionGroups.map((group) => [ normalizeText(group?.key, 80), (Array.isArray(group?.options) ? group.options : []) .map((value) => normalizeText(value, 160)) .filter(Boolean), ]).filter(([key]) => key)), }; } function normalizeProductCodeConfig(input = {}, remoteMeta = {}) { const meta = normalizeProductCodeMeta(remoteMeta); const config = { operationCenter: normalizeText(input.operationCenter, 160), categoryName: normalizeText(input.categoryName, 255), }; if (!meta.operationCenters.includes(config.operationCenter)) { const error = new Error('运营中心必须选择公司商品编码接口返回的有效选项'); error.status = 400; throw error; } if (!meta.categories.includes(config.categoryName)) { const error = new Error('商品分类必须选择公司商品编码接口返回的有效选项'); error.status = 400; throw error; } for (const field of PRODUCT_CODE_CONFIG_FIELDS) { config[field] = normalizeText(input[field], 160); if (!meta.tagOptions[field]?.includes(config[field])) { const error = new Error(`公司商品编码配置「${field}」无效,请重新选择`); error.status = 400; throw error; } } return config; } function buildProductCodeRequest(config, rows, user = {}) { const skuItems = rows.map((row, index) => { const productName = normalizeText(row.selectedSkuName, 255); if (!productName) { const error = new Error(`第 ${index + 1} 个SKU缺少确认后的SKU名称`); error.status = 400; throw error; } const colorName = normalizeText(row.colorName, 255); const colorSpec = normalizeText( row.colorSpec || [colorName, normalizeText(row.snapshot?.size, 255)].filter(Boolean).join(';'), 500 ) || productName; const item = { productName, colorSpec }; for (const field of ['lengthCm', 'widthCm', 'heightCm']) { if (Number(row[field]) > 0) item[field] = Number(row[field]); } if (Number(row.weight) >= 0 && row.weight !== null) item.weight = Number(row.weight); return item; }); return { ...config, sourceSystem: 'product-planning-system', operatorName: normalizeText(getDingtalkUserName(user), 160) || '系统用户', operatorDepartment: config.operationCenter, skuItems, }; } function buildProductCodeRequestHash(payload, rows) { return crypto.createHash('sha256').update(JSON.stringify({ payload, skuCodes: rows.map((row) => row.skuCode), })).digest('hex'); } function validateProductCodeResponse(response, rows) { const items = Array.isArray(response?.items) ? response.items : []; if (!normalizeText(response?.styleCode, 160) || items.length !== rows.length) { throw new ProductCodePublicApiError('公司商品编码返回的款式编码或SKU数量不完整', { status: 502, code: 'PRODUCT_CODE_RESPONSE_MISMATCH', indeterminate: true, responseData: response, }); } return items.map((item, index) => { const expectedName = rows[index].selectedSkuName; const productName = normalizeText(item?.productName, 255); const productCode = normalizeText(item?.productCode, 160); if (!productCode || productName !== expectedName) { throw new ProductCodePublicApiError( `公司商品编码第 ${index + 1} 项与SKU名称不一致,已停止回写`, { status: 502, code: 'PRODUCT_CODE_NAME_MISMATCH', indeterminate: true, responseData: response, } ); } return { skuCode: rows[index].skuCode, productCode, productName, colorSpec: normalizeText(item?.colorSpec, 500), }; }); } function pruneSelectionSkuPayload(payload, retainedSkuCodes, options = {}) { if (!payload || typeof payload !== 'object') return payload; const normalizedCodes = Array.from(new Set((retainedSkuCodes || []) .map((skuCode) => normalizeText(skuCode, 160).toUpperCase()) .filter(Boolean))); const retained = new Set(normalizedCodes); const nextPayload = { ...payload }; if (Array.isArray(payload.rows)) { nextPayload.rows = payload.rows.filter((row) => retained.has( normalizeText(row?.skuCode || row?.sku_code, 160).toUpperCase() )); } if (options.syncSelection === true) { nextPayload.selectedSkuCodes = normalizedCodes; nextPayload.total = normalizedCodes.length; } return nextPayload; } function buildSelectionFissionProfitPricingContext(plan, itemId) { const item = plan.items.find((row) => Number(row.id) === Number(itemId)); if (!item || !item.selectedSpuCode) { const error = new Error('选品池SPU定价任务不存在'); error.status = 404; throw error; } const isAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const activityBySku = new Map((isAlibaba1688Pending ? [] : item.activityPricingRows || []).map((row) => [ String(row.skuCode || '').toUpperCase(), row, ])); return { planId: plan.id, schemeCode: plan.schemeCode, itemId: item.id, taskCode: item.taskCode, marketItemId: item.marketItemId, spuCode: item.selectedSpuCode, listedItemId: item.listedItemId, pricingTableSavedAt: item.pricingTableSavedAt, pricingTableSavedBy: item.pricingTableSavedBy, pricingTableVersion: item.pricingTableVersion, productCodeStatus: item.productCodeStatus || 'not_requested', productCodeStyleCode: item.productCodeStyleCode || '', productCodeRequestHash: item.productCodeRequestHash || '', productCodeConfig: item.productCodeConfig || null, productCodeErrorMessage: item.productCodeErrorMessage || '', productCodeRequestedAt: item.productCodeRequestedAt || null, productCodeCompletedAt: item.productCodeCompletedAt || null, skuSceneImages: item.skuSceneImages, skuNameCandidates: item.skuNameCandidates, workflowProfile: getSelectionWorkflowProfile(item), skuSnapshot: item.skuSnapshot, metadata: item.metadata, rows: (item.pricingRows || []).map((row) => ({ ...row, groupPrice: activityBySku.get(String(row.skuCode || '').toUpperCase())?.finalGroupPrice ?? activityBySku.get(String(row.skuCode || '').toUpperCase())?.groupPrice ?? row.groupPrice ?? row.finalPrice, })), }; } async function getSelectionFissionProductCodeMeta() { return normalizeProductCodeMeta(await getProductCodeMeta()); } async function batchUpdateSelectionPricingRows( connection, tableName, planItemId, rows, fieldDefinitions, fixedAssignments = [] ) { if (!rows.length) return; const params = []; const caseAssignments = fieldDefinitions.map(([columnName, getValue]) => { const cases = rows.map((row) => { params.push(row.skuCode, getValue(row)); return 'WHEN ? THEN ?'; }); return `${escapeIdentifier(columnName)} = CASE UPPER(sku_code) ${cases.join(' ')} ELSE ${escapeIdentifier(columnName)} END`; }); params.push(planItemId, ...rows.map((row) => row.skuCode)); await connection.execute( `UPDATE ${escapeIdentifier(tableName)} SET ${[...caseAssignments, ...fixedAssignments].join(', ')} WHERE plan_item_id = ? AND UPPER(sku_code) IN (${rows.map(() => '?').join(', ')})`, params ); } async function saveSelectionFissionProfitPricingTable( planId, itemId, input = {}, user = {}, options = {} ) { await ensureSelectionFissionSchema(); const context = options.pricingContext || await getSelectionFissionProfitPricingContext(planId, itemId); const spuCode = normalizeText(input.spuCode || input.spu, 160).toUpperCase(); if (spuCode && spuCode !== context.spuCode.toUpperCase()) { const error = new Error(`当前定价表属于SPU ${context.spuCode},不能保存为 ${spuCode}`); error.status = 409; throw error; } if (context.listedItemId) { const error = new Error(`该定价表已绑定上架商品ID ${context.listedItemId},不能覆盖历史上架定价`); error.status = 409; throw error; } const normalizedRows = normalizeProfitPricingRows(input.rows); const expectedSkuCodes = new Set(context.rows.map((row) => String(row.skuCode || '').toUpperCase())); const incomingSkuCodes = new Set(normalizedRows.map((row) => row.skuCode)); const missingSkuCodes = [...expectedSkuCodes].filter((skuCode) => !incomingSkuCodes.has(skuCode)); const unknownSkuCodes = [...incomingSkuCodes].filter((skuCode) => !expectedSkuCodes.has(skuCode)); if (unknownSkuCodes.length > 0) { const error = new Error('定价表包含不属于当前上架任务的SKU,请重新载入后再保存'); error.status = 409; error.data = { unknownSkuCodes }; throw error; } const incomingRowsBySku = new Map(normalizedRows.map((row) => [row.skuCode, row])); let rows = context.rows .map((row) => incomingRowsBySku.get(String(row.skuCode || '').toUpperCase())) .filter(Boolean); const retainedSkuCodes = rows.map((row) => row.skuCode); const nextSkuSceneImages = pruneSelectionSkuPayload( context.skuSceneImages, retainedSkuCodes, { syncSelection: true } ); const nextSkuNameCandidates = pruneSelectionSkuPayload( context.skuNameCandidates, retainedSkuCodes ); const isAlibaba1688Pending = context.workflowProfile === ALIBABA_1688_PENDING_PROFILE; let globalEncodingState = null; if (isAlibaba1688Pending) { const globalEncoding = await loadAlibaba1688GlobalEncoding({ marketItemId: context.marketItemId, selectedSpuCode: context.spuCode, workflowProfile: context.workflowProfile, metadata: context.metadata, }); globalEncodingState = buildAlibaba1688GlobalEncodingState(globalEncoding); const encodedSnapshot = applyAlibaba1688GlobalEncodingToSnapshot( context.skuSnapshot, globalEncoding ); const encodedBySkuCode = new Map(encodedSnapshot.rows.map((row) => [ normalizeText(row.skuCode, 160).toUpperCase(), row, ])); rows = rows.map((row) => { const encoded = encodedBySkuCode.get(row.skuCode); if (!encoded?.internalProductCode) { throw alibaba1688GlobalEncodingError( `SKU ${row.skuCode} 缺少可复用的全局商品编码`, 'ALIBABA_1688_PRODUCT_ENCODING_MAPPING_INCOMPLETE' ); } return { ...row, internalProductCode: encoded.internalProductCode, productCodeProductName: encoded.productCodeProductName, productCodeColorSpec: encoded.productCodeColorSpec, }; }); } const savedBy = getDingtalkUserName(user); const currentMetadata = context.metadata && typeof context.metadata === 'object' ? context.metadata : {}; const supplyDecision = buildSelectionSupplyDecision( rows, currentMetadata, isAlibaba1688Pending ? '1688' : 'company' ); const nextMetadata = buildSelectionSupplyMetadata( currentMetadata, supplyDecision, isAlibaba1688Pending ? '1688' : 'company' ); await planningDatabase.withTransaction(async (connection) => { await batchUpdateSelectionPricingRows( connection, PRICING_TABLE, context.itemId, rows, [ ['brand_name', (row) => row.brandName || null], ['installation_fee', (row) => row.installationFee], ['freight_national', (row) => row.freightNational], ['gross_margin', (row) => row.grossMargin], ['discount_amount', (row) => row.discountAmount], ['activity_discount', (row) => row.activityDiscount], ['platform_commission_ratio', (row) => row.platformCommissionRatio], ['promotion_ratio', (row) => row.promotionRatio], ['marketing_ad_ratio', (row) => row.marketingAdRatio], ['farming_ratio', (row) => row.farmingRatio], ['aftersale_ratio', (row) => row.aftersaleRatio], ['insurance_ratio', (row) => row.insuranceRatio], ['brand_usage_ratio', (row) => row.brandUsageRatio], ['operating_profit', (row) => row.operatingProfit], ['operating_profit_ratio', (row) => row.operatingProfitRatio], ['final_price', (row) => row.finalPrice], ['group_price', (row) => row.groupPrice], ['pricing_saved_by', () => savedBy], ['selected_sku_name', (row) => row.selectedSkuName || null], ['operator_name', () => savedBy], ...(isAlibaba1688Pending ? [ ['internal_product_code', (row) => row.internalProductCode], ['product_code_product_name', (row) => row.productCodeProductName || null], ['product_code_color_spec', (row) => row.productCodeColorSpec || null], ] : []), ], ['pricing_saved_at = NOW()', 'pricing_version = pricing_version + 1', 'operated_at = NOW()', 'updated_at = NOW()'] ); if (isAlibaba1688Pending) { await connection.execute( `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} WHERE plan_item_id = ?`, [context.itemId] ); } else { await batchUpdateSelectionPricingRows( connection, ACTIVITY_PRICING_TABLE, context.itemId, rows, [ ['landed_price', (row) => row.finalPrice], ['coupon_amount', (row) => row.discountAmount], ['activity_discount', (row) => row.activityDiscount], ['group_price', (row) => row.groupPrice], ['final_group_price', (row) => row.groupPrice], ['raw_payload_json', (row) => safeJsonStringify({ source: 'pricing-profit-calculator', savedAt: new Date().toISOString(), row: row.snapshot, })], ], ['updated_at = NOW()'] ); } if (missingSkuCodes.length > 0) { const placeholders = missingSkuCodes.map(() => '?').join(', '); if (!isAlibaba1688Pending) { await connection.execute( `DELETE FROM ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} WHERE plan_item_id = ? AND UPPER(sku_code) IN (${placeholders})`, [context.itemId, ...missingSkuCodes] ); } await connection.execute( `DELETE FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ? AND UPPER(sku_code) IN (${placeholders})`, [context.itemId, ...missingSkuCodes] ); } const productCodeSql = isAlibaba1688Pending ? `, product_code_status = 'completed', product_code_style_code = ?, product_code_request_hash = ?, product_code_config_json = ?, product_code_request_json = ?, product_code_response_json = ?, product_code_error_message = NULL, product_code_requested_at = ?, product_code_completed_at = ?` : ''; await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_scene_images_json = COALESCE(?, sku_scene_images_json), sku_name_candidates_json = COALESCE(?, sku_name_candidates_json), metadata_json = ?, pricing_table_saved_at = NOW(), pricing_table_saved_by = ?, pricing_table_version = pricing_table_version + 1, workflow_status = ?, workflow_step = 8, workflow_error_message = NULL${productCodeSql}, updated_at = NOW() WHERE id = ?`, [ nextSkuSceneImages ? safeJsonStringify(nextSkuSceneImages) : null, nextSkuNameCandidates ? safeJsonStringify(nextSkuNameCandidates) : null, safeJsonStringify(nextMetadata), savedBy, isAlibaba1688Pending ? 'waiting' : 'completed', ...(isAlibaba1688Pending ? [ globalEncodingState.styleCode || null, globalEncodingState.requestHash, safeJsonStringify(globalEncodingState.config), safeJsonStringify(globalEncodingState.request), safeJsonStringify(globalEncodingState.response), toMysqlDateTime(globalEncodingState.requestedAt), toMysqlDateTime(globalEncodingState.completedAt), ] : []), context.itemId, ] ); }); const updatedPlan = await getSelectionFissionPlanDetail(planId); await syncSelectionFissionPlanWorkflowState(planId, updatedPlan); await recordCurrentSelectionFissionRevision({ planId, planItemId: context.itemId, revisionType: 'pricing', summary: isAlibaba1688Pending ? `保存1688定价表并复用全局SKU编码,共${rows.length}个SKU` : `保存定价表,共${rows.length}个SKU`, user, plan: updatedPlan, }); return buildSelectionFissionProfitPricingContext(updatedPlan, itemId); } async function generateSelectionFissionSkuNames(planId, planItemId) { const item = await getPlanItem(planId, planItemId); if (!item.selectedSpuCode) { const error = new Error('请先确认公司SPU后再生成SKU名称'); error.status = 400; throw error; } const dbRows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ? ORDER BY sku_code ASC, id ASC`, [item.id] ); const pricingRows = dbRows.map(mapPricingRow); if (pricingRows.length === 0) { const error = new Error('请先生成定价表,再使用AI生成SKU名称'); error.status = 400; throw error; } const generated = await generateSkuNameCandidates({ spuCode: item.selectedSpuCode, spuName: item.selectedSpuName, marketTitle: item.title, }, pricingRows.map((row) => ({ skuCode: row.skuCode, originalSkuName: row.originalSkuName, size: '', colorName: row.color || row.rawPayload?.sku?.colorName, imageUrl: row.generatedImageUrl || row.skuImageUrl, }))); await planningDatabase.withTransaction(async (connection) => { for (const result of generated.rows) { await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET ai_sku_name_candidates_json = ?, selected_sku_name = ?, ai_sku_name_model = ?, ai_sku_name_generated_at = ?, updated_at = NOW() WHERE plan_item_id = ? AND sku_code = ?`, [ safeJsonStringify(result.names), result.names[0], generated.model, new Date(generated.generatedAt), item.id, result.skuCode, ] ); } }); const detail = await getSelectionFissionPlanDetail(item.planId); return { plan: detail, planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, model: generated.model, generatedAt: generated.generatedAt, generatedSkuCount: generated.rows.length, candidateCountPerSku: 5, usage: generated.usage, }; } function buildBulkSkuNameSelections(item = {}, selectionMode = 'candidate_1_all') { const candidateMatch = String(selectionMode).match(/^candidate_([1-5])_all$/); const candidateIndex = candidateMatch ? Number(candidateMatch[1]) - 1 : -1; const useOriginalName = selectionMode === 'original_all'; if (!useOriginalName && candidateIndex < 0) return []; const selectedSkuCodes = new Set(getWorkflowSelectedSkuRows(item).map((row) => row.skuCode)); const skuRows = new Map( (Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []) .map((row) => [row.skuCode, row]) ); return (Array.isArray(item.skuNameCandidates?.rows) ? item.skuNameCandidates.rows : []) .filter((row) => selectedSkuCodes.has(row.skuCode)) .map((row) => ({ skuCode: row.skuCode, selectedName: normalizeText( useOriginalName ? row.originalSkuName || skuRows.get(row.skuCode)?.skuName || skuRows.get(row.skuCode)?.productName : row.names?.[candidateIndex], 255 ), })) .filter((row) => row.skuCode && row.selectedName); } async function updateSelectionFissionSkuName(planId, planItemId, input = {}, user = {}) { const item = await getPlanItem(planId, planItemId); const selectionMode = normalizeText(input.selectionMode || input.selection_mode, 40); if (selectionMode === 'original_all' || /^candidate_[1-5]_all$/.test(selectionMode)) { const selectedSkuRows = getWorkflowSelectedSkuRows(item); const selections = buildBulkSkuNameSelections(item, selectionMode); if (selections.length !== selectedSkuRows.length || selections.length === 0) { const error = new Error('部分已选SKU缺少该批量方案名称,请逐条核对'); error.status = 409; error.code = 'SKU_ORIGINAL_NAME_INCOMPLETE'; throw error; } const selectionMap = new Map(selections.map((row) => [row.skuCode, row.selectedName])); const nextCandidates = { ...item.skuNameCandidates, rows: item.skuNameCandidates.rows.map((row) => ( selectionMap.has(row.skuCode) ? { ...row, selectedName: selectionMap.get(row.skuCode) } : row )), }; await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_name_candidates_json = ?, workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [safeJsonStringify(nextCandidates), item.id] ); for (const chunk of splitIntoBatches(selections, 100)) { const caseSql = chunk.map(() => 'WHEN ? THEN ?').join(' '); const codeSql = chunk.map(() => '?').join(', '); const caseParams = chunk.flatMap((row) => [row.skuCode, row.selectedName]); await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET selected_sku_name = CASE sku_code ${caseSql} ELSE selected_sku_name END, updated_at = NOW() WHERE plan_item_id = ? AND sku_code IN (${codeSql})`, [...caseParams, item.id, ...chunk.map((row) => row.skuCode)] ); } }); const detail = await getSelectionFissionPlanDetail(item.planId); await Promise.all([ syncSelectionFissionPlanWorkflowState(item.planId, detail), recordCurrentSelectionFissionRevision({ planId: item.planId, planItemId: item.id, revisionType: 'sku_names', summary: selectionMode === 'original_all' ? `批量保留 ${selections.length} 个1688原始SKU名称` : `批量采用 ${selections.length} 个SKU的AI方案${selectionMode.match(/\d/)?.[0]}`, user, plan: detail, }), ]); return { plan: detail, planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, updatedCount: selections.length, selectionMode, }; } const skuCode = normalizeText(input.skuCode || input.sku_code, 160); const clearSelection = input.clearSelection === true || input.clear_selection === true || input.selectedSkuName === '' || input.selected_sku_name === ''; const selectedSkuName = normalizeText( input.selectedSkuName || input.selected_sku_name || input.skuName || input.sku_name, 255 ); if (!skuCode || (!clearSelection && !selectedSkuName)) { const error = new Error('缺少SKU编码或选择的SKU名称'); error.status = 400; throw error; } const candidateRows = Array.isArray(item.skuNameCandidates?.rows) ? item.skuNameCandidates.rows : []; const candidateRow = candidateRows.find((row) => row.skuCode === skuCode); const skuRow = item.skuSnapshot?.rows?.find((row) => row.skuCode === skuCode); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PRICING_TABLE)} WHERE plan_item_id = ? AND sku_code = ? LIMIT 1`, [item.id, skuCode] ); const pricingRow = rows[0] ? mapPricingRow(rows[0]) : null; const allowedNames = new Set([ candidateRow?.originalSkuName, skuRow?.skuName, skuRow?.productName, ...(Array.isArray(candidateRow?.names) ? candidateRow.names : []), pricingRow?.originalSkuName, ...(pricingRow?.optimizedSkuNames || []), ].filter(Boolean)); if (!clearSelection && !allowedNames.has(selectedSkuName)) { const error = new Error('选择的SKU名称不在当前AI候选名称中'); error.status = 400; throw error; } if (candidateRow) { const nextCandidates = { ...item.skuNameCandidates, rows: candidateRows.map((row) => ( row.skuCode === skuCode ? { ...row, selectedName: clearSelection ? '' : selectedSkuName } : row )), }; await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET sku_name_candidates_json = ?, workflow_status = 'waiting', workflow_error_message = NULL, updated_at = NOW() WHERE id = ?`, [safeJsonStringify(nextCandidates), item.id] ); } if (pricingRow) { await planningDatabase.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET selected_sku_name = ?, updated_at = NOW() WHERE plan_item_id = ? AND sku_code = ?`, [clearSelection ? null : selectedSkuName, item.id, skuCode] ); } const detail = await getSelectionFissionPlanDetail(item.planId); await Promise.all([ syncSelectionFissionPlanWorkflowState(item.planId, detail), recordCurrentSelectionFissionRevision({ planId: item.planId, planItemId: item.id, revisionType: 'sku_names', summary: clearSelection ? `取消SKU ${skuCode} 的上架名称` : `确认SKU ${skuCode} 的上架名称`, user, plan: detail, }), ]); return { plan: detail, planItem: detail.items.find((nextItem) => nextItem.id === item.id) || null, skuCode, selectedSkuName: clearSelection ? '' : selectedSkuName, }; } async function updateSelectionFissionItemListing(planId, planItemId, input = {}) { const item = await getPlanItem(planId, planItemId); const listedItemId = normalizeText(input.listedItemId || input.listed_item_id, 160); const listingStatus = input.status === 'listed' || listedItemId ? 'listed' : 'pending'; await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listed_item_id = ?, listing_status = ?, updated_at = NOW() WHERE id = ?`, [listedItemId || null, listingStatus, item.id] ); await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET listed_item_id = ?, updated_at = NOW() WHERE plan_item_id = ?`, [listedItemId || null, item.id] ); }); await recomputePlanListedState(item.planId); return getSelectionFissionPlanDetail(item.planId); } async function updateSelectionFissionPlanStatus(planId, input = {}) { await ensureSelectionFissionSchema(); const error = new Error('自动上架状态由第三方回传商品ID后自动更新,不能手工修改'); error.status = 409; error.data = { planId: normalizePositiveInteger(planId, 0), requestedStatus: normalizeText(input.status, 20), }; throw error; } function normalizeAutoListingProductId(input = {}) { if (typeof input === 'string' || typeof input === 'number') { return normalizeText(input, 160); } return normalizeText( input.productId || input.product_id || input.autoListedProductId || input.auto_listed_product_id || input.listedProductId || input.listed_product_id || input.listedItemId || input.listed_item_id || input.goodsId || input.goods_id || input.itemId || input.item_id, 160 ); } function normalizeCallbackUserName(input = {}) { return normalizeText( input.dingtalkUserName || input.dingtalk_user_name || input.dingtalkUsername || input.userName || input.user_name, 120 ); } function normalizeCallbackShopName(input = {}) { return normalizeText( input.listingShopName || input.listing_shop_name || input.shopName || input.shop_name, 255 ); } function normalizeClaimToken(input = {}) { return normalizeText( input.claimToken || input.claim_token || input.taskClaimToken || input.task_claim_token || input.task?.claimToken || input.task?.claim_token || input.claim?.claimToken || input.claim?.claim_token, 500 ); } async function findExternalSelectionTask(taskIdentity, dingtalkUserName) { const normalizedIdentity = normalizeText(taskIdentity, 160); if (!normalizedIdentity) { const error = new Error('缺少选品任务ID或任务编码'); error.status = 400; throw error; } if (!dingtalkUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const taskId = /^\d+$/.test(normalizedIdentity) ? Number(normalizedIdentity) : 0; const rows = await planningDatabase.query( `SELECT i.*, p.scheme_code, p.dingtalk_user_name, p.dingtalk_username, p.listing_shop_id, p.listing_shop_name, p.listing_shop_platform, p.listing_shop_snapshot_json FROM ${escapeIdentifier(ITEM_TABLE)} i INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id WHERE (i.id = ? OR i.task_code = ?) AND ( NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? OR ( JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL AND ( TRIM(COALESCE(p.dingtalk_user_name, '')) = ? OR TRIM(COALESCE(p.dingtalk_username, '')) = ? ) ) ) AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL LIMIT 1`, [ taskId, normalizedIdentity, dingtalkUserName, dingtalkUserName, dingtalkUserName, dingtalkUserName, ] ); if (!rows[0]) { const planRows = await planningDatabase.query( `SELECT i.id, i.task_code FROM ${escapeIdentifier(ITEM_TABLE)} i INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id WHERE (p.id = ? OR p.scheme_code = ?) AND ( NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? OR ( JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL AND ( TRIM(COALESCE(p.dingtalk_user_name, '')) = ? OR TRIM(COALESCE(p.dingtalk_username, '')) = ? ) ) ) AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL ORDER BY i.slot_no ASC, i.variant_no ASC`, [ taskId, normalizedIdentity, dingtalkUserName, dingtalkUserName, dingtalkUserName, dingtalkUserName, ] ); if (planRows.length > 1) { const error = new Error('该裂变方案包含多个SPU上架子任务,请使用子任务 taskId 或 taskCode 回传'); error.status = 409; error.data = { tasks: planRows.map((row) => ({ taskId: Number(row.id), taskCode: normalizeText(row.task_code, 96), })), }; throw error; } if (planRows[0]) { return findExternalSelectionTask( planRows[0].task_code || planRows[0].id, dingtalkUserName ); } const error = new Error('未找到对应的SPU自动上架子任务'); error.status = 404; throw error; } return rows[0]; } function assertValidClaimToken(taskRow, input = {}) { const expectedHash = normalizeText(taskRow.claim_token_hash, 64); if (!expectedHash) return; if (taskRow.claim_expires_at && new Date(taskRow.claim_expires_at).getTime() <= Date.now()) { const error = new Error('任务领取令牌已过期,请重新领取任务后再回传'); error.status = 409; error.data = { code: 'CLAIM_TOKEN_EXPIRED', taskId: Number(taskRow.id) || undefined, taskCode: normalizeText(taskRow.task_code, 96) || undefined, requiresReclaim: true, claimExpiresAt: taskRow.claim_expires_at, }; throw error; } const claimToken = normalizeClaimToken(input); if (!claimToken) { const error = new Error('任务领取令牌缺失,请回传领取接口返回的 data.claimToken'); error.status = 409; error.data = { code: 'CLAIM_TOKEN_MISSING', taskId: Number(taskRow.id) || undefined, taskCode: normalizeText(taskRow.task_code, 96) || undefined, requiresReclaim: false, acceptedLocations: [ 'X-Task-Claim-Token 请求头', 'claimToken 请求体字段', 'task.claimToken 请求体字段', ], }; throw error; } if (hashClaimToken(claimToken) !== expectedHash) { const error = new Error('任务领取令牌已失效或不匹配,请重新领取该子任务后再回传'); error.status = 409; error.data = { code: 'CLAIM_TOKEN_MISMATCH', taskId: Number(taskRow.id) || undefined, taskCode: normalizeText(taskRow.task_code, 96) || undefined, requiresReclaim: true, claimExpiresAt: taskRow.claim_expires_at || undefined, }; throw error; } } async function insertCallbackHistory(connection, taskRow, record, context = {}) { await connection.execute( `INSERT INTO ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} ( plan_id, plan_item_id, task_code, callback_type, product_id, error_code, error_message, dingtalk_user_name, worker_id, api_key_id, api_key_name, remote_ip, callback_payload_json, received_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, [ taskRow.plan_id, taskRow.id, normalizeText(taskRow.task_code || taskRow.scheme_code, 64), record.callbackType, record.productId || null, record.errorCode || null, record.errorMessage || null, record.dingtalkUserName, record.workerId || normalizeText(taskRow.claimed_by, 160) || null, context.apiKeyId ? String(context.apiKeyId) : null, normalizeText(context.apiKeyName, 120) || null, normalizeText(context.remoteIp, 80) || null, safeJsonStringify(record), ] ); } async function claimSelectionFissionDeliveryTask(input = {}, context = {}) { await ensureSelectionFissionSchema(); const dingtalkUserName = normalizeCallbackUserName(input); const workerId = normalizeText(input.workerId || input.worker_id || input.instanceId || input.instance_id, 160); if (!dingtalkUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } if (!workerId) { const error = new Error('缺少自动化进程 workerId'); error.status = 400; throw error; } const leaseSeconds = DEFAULT_CLAIM_LEASE_SECONDS; const clientId = normalizeText(input.clientId || input.client_id, 64).toLowerCase(); const supportedShopIds = new Set( (Array.isArray(input.shopIds || input.shop_ids) ? (input.shopIds || input.shop_ids) : []) .map((value) => normalizeText(value, 160)) .filter(Boolean) ); const taskIdentity = normalizeText( input.taskId || input.task_id || input.taskCode || input.task_code || input.schemeCode || input.scheme_code, 160 ); const identitySql = taskIdentity ? 'AND (i.id = ? OR i.task_code = ? OR p.id = ? OR p.scheme_code = ?)' : ''; const identityParams = taskIdentity ? [ /^\d+$/.test(taskIdentity) ? Number(taskIdentity) : 0, taskIdentity, /^\d+$/.test(taskIdentity) ? Number(taskIdentity) : 0, taskIdentity, ] : []; const candidateRows = await planningDatabase.query( `SELECT i.*, p.scheme_code, p.dingtalk_user_name, p.dingtalk_username, p.listing_shop_id, p.listing_shop_snapshot_json FROM ${escapeIdentifier(ITEM_TABLE)} i INNER JOIN ${escapeIdentifier(PLAN_TABLE)} p ON p.id = i.plan_id WHERE ( ( NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserName')), '') = ? OR NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUsername')), '') = ? ) OR ( JSON_EXTRACT(p.listing_shop_snapshot_json, '$.executorUserId') IS NULL AND ( TRIM(COALESCE(p.dingtalk_user_name, '')) = ? OR TRIM(COALESCE(p.dingtalk_username, '')) = ? ) ) ) AND p.workflow_status = 'completed' AND NULLIF(TRIM(i.selected_spu_code), '') IS NOT NULL AND NULLIF(TRIM(i.listed_item_id), '') IS NULL AND NULLIF(TRIM(p.listing_shop_id), '') IS NOT NULL AND ( i.listing_status IN ('pending', 'failed') OR (i.listing_status = 'claimed' AND i.claim_expires_at < NOW()) ) ${identitySql} ORDER BY i.listing_status = 'failed' ASC, i.created_at ASC, i.id ASC LIMIT 100`, [dingtalkUserName, dingtalkUserName, dingtalkUserName, dingtalkUserName, ...identityParams] ); for (const candidate of candidateRows) { const listingShopId = normalizeText(candidate.listing_shop_id, 160); if (supportedShopIds.size > 0 && !supportedShopIds.has(listingShopId)) continue; const listingShopSnapshot = safeJsonParse(candidate.listing_shop_snapshot_json, {}); const assignedClientId = normalizeText(listingShopSnapshot.clientId, 64).toLowerCase(); if (clientId && assignedClientId && clientId !== assignedClientId) continue; const plan = await getSelectionFissionPlanDetail(candidate.plan_id); const item = plan.items.find((row) => Number(row.id) === Number(candidate.id)); if (!item || !buildDeliveryTaskPayload(plan, item).readiness.complete) continue; // Query before acquiring the lease so a slow video service cannot leave a // task claimed when the caller times out before receiving the payload. const [deliveryVideoAssets, deliveryMaterials] = await Promise.all([ fetchVideoAssets(item.selectedSpuCode), buildDeliveryMaterials(item), ]); const claimToken = `sf_claim_${crypto.randomBytes(32).toString('base64url')}`; const claimTokenHash = hashClaimToken(claimToken); const result = await planningDatabase.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_status = 'claimed', claim_token_hash = ?, claimed_by = ?, claimed_at = NOW(), claim_expires_at = DATE_ADD(NOW(), INTERVAL ? SECOND), claim_count = claim_count + 1, listing_error_code = NULL, listing_error_message = NULL, updated_at = NOW() WHERE id = ? AND NULLIF(TRIM(listed_item_id), '') IS NULL AND ( listing_status IN ('pending', 'failed') OR (listing_status = 'claimed' AND claim_expires_at < NOW()) )`, [claimTokenHash, workerId, leaseSeconds, item.id] ); if (Number(result?.affectedRows || 0) !== 1) continue; await recomputePlanListedState(plan.id); const claimedPlan = await getSelectionFissionPlanDetail(plan.id); const claimedItem = claimedPlan.items.find((row) => Number(row.id) === Number(item.id)); const task = { ...buildDeliveryTaskPayload(claimedPlan, claimedItem, { videoAssets: deliveryVideoAssets, materials: deliveryMaterials, }), // Keep the lease secret beside the task for clients that persist data.task only. claimToken, }; return { claimed: true, claimToken, leaseSeconds, claimExpiresAt: claimedItem.claimExpiresAt, workerId, task, apiKeyId: context.apiKeyId || undefined, }; } return { claimed: false, task: null, retryAfterSeconds: 30, reason: taskIdentity ? '指定任务不存在、资料未齐、已被其他进程领取或已完成' : '当前没有可领取且资料完整的自动上架任务', }; } async function receiveSelectionFissionAutoListingResult(taskIdentity, input = {}, context = {}) { await ensureSelectionFissionSchema(); const productId = normalizeAutoListingProductId(input); const dingtalkUserName = normalizeCallbackUserName(input); if (!productId) { const error = new Error('缺少自动上架后的商品ID'); error.status = 400; throw error; } const taskRow = await findExternalSelectionTask(taskIdentity, dingtalkUserName); assertValidClaimToken(taskRow, input); const callbackRecord = { callbackType: 'success', productId, taskId: taskRow.id, taskCode: normalizeText(taskRow.task_code || taskRow.scheme_code, 96), schemeId: Number(taskRow.plan_id), schemeCode: normalizeText(taskRow.scheme_code, 64), dingtalkUserName, listingShopName: normalizeCallbackShopName(input) || undefined, workerId: normalizeText(input.workerId || input.worker_id || taskRow.claimed_by, 160) || undefined, apiKeyId: context.apiKeyId || undefined, apiKeyName: normalizeText(context.apiKeyName, 120) || undefined, remoteIp: normalizeText(context.remoteIp, 80) || undefined, receivedAt: new Date().toISOString(), }; const callbackPayloadJson = safeJsonStringify(callbackRecord); await planningDatabase.withTransaction(async (connection) => { await connection.execute( `UPDATE ${escapeIdentifier(LISTING_RESULT_TABLE)} SET is_current = 0, updated_at = NOW() WHERE plan_item_id = ? AND is_current = 1`, [taskRow.id] ); await connection.execute( `INSERT INTO ${escapeIdentifier(LISTING_RESULT_TABLE)} ( plan_id, plan_item_id, task_code, product_id, dingtalk_user_name, api_key_id, api_key_name, callback_payload_json, callback_count, is_current, first_received_at, last_received_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE dingtalk_user_name = VALUES(dingtalk_user_name), api_key_id = VALUES(api_key_id), api_key_name = VALUES(api_key_name), callback_payload_json = VALUES(callback_payload_json), callback_count = callback_count + 1, is_current = 1, last_received_at = NOW(), updated_at = NOW()`, [ taskRow.plan_id, taskRow.id, normalizeText(taskRow.task_code || taskRow.scheme_code, 64), productId, dingtalkUserName, context.apiKeyId ? String(context.apiKeyId) : null, normalizeText(context.apiKeyName, 120) || null, callbackPayloadJson, ] ); await insertCallbackHistory(connection, taskRow, callbackRecord, context); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_completed_at = CASE WHEN listed_item_id = ? THEN COALESCE(listing_completed_at, NOW()) ELSE NOW() END, listed_item_id = ?, listing_error_code = NULL, listing_error_message = NULL, listing_status = 'listed', updated_at = NOW() WHERE id = ?`, [productId, productId, taskRow.id] ); await connection.execute( `UPDATE ${escapeIdentifier(PRICING_TABLE)} SET listed_item_id = ?, updated_at = NOW() WHERE plan_item_id = ?`, [productId, taskRow.id] ); await connection.execute( `UPDATE ${escapeIdentifier(ACTIVITY_PRICING_TABLE)} SET listed_item_id = ?, updated_at = NOW() WHERE plan_item_id = ?`, [productId, taskRow.id] ); }); await recomputePlanListedState(taskRow.plan_id); const plan = await getSelectionFissionPlanDetail(taskRow.plan_id); const item = plan.items.find((row) => Number(row.id) === Number(taskRow.id)); return { taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, productId: item.listedItemId, autoListedProductId: item.listedItemId, status: '上架完成', statusCode: 'completed', completedAt: item.listingCompletedAt, saved: true, }; } async function receiveSelectionFissionAutoListingFailure(taskIdentity, input = {}, context = {}) { await ensureSelectionFissionSchema(); const dingtalkUserName = normalizeCallbackUserName(input); const errorCode = normalizeText(input.errorCode || input.error_code || input.code, 120); const errorMessage = normalizeText( input.errorMessage || input.error_message || input.reason || input.message, 1000 ); if (!errorMessage) { const error = new Error('缺少上架失败原因'); error.status = 400; throw error; } const taskRow = await findExternalSelectionTask(taskIdentity, dingtalkUserName); if (normalizeText(taskRow.listed_item_id, 160)) { const error = new Error('该任务已经上架完成,不能再回传失败'); error.status = 409; throw error; } assertValidClaimToken(taskRow, input); const callbackRecord = { callbackType: 'failure', errorCode: errorCode || 'AUTO_LISTING_FAILED', errorMessage, taskId: taskRow.id, taskCode: normalizeText(taskRow.task_code || taskRow.scheme_code, 96), schemeId: Number(taskRow.plan_id), schemeCode: normalizeText(taskRow.scheme_code, 64), dingtalkUserName, workerId: normalizeText(input.workerId || input.worker_id || taskRow.claimed_by, 160) || undefined, apiKeyId: context.apiKeyId || undefined, apiKeyName: normalizeText(context.apiKeyName, 120) || undefined, remoteIp: normalizeText(context.remoteIp, 80) || undefined, receivedAt: new Date().toISOString(), }; const callbackPayloadJson = safeJsonStringify(callbackRecord); await planningDatabase.withTransaction(async (connection) => { await insertCallbackHistory(connection, taskRow, callbackRecord, context); await connection.execute( `UPDATE ${escapeIdentifier(ITEM_TABLE)} SET listing_status = 'failed', listing_error_code = ?, listing_error_message = ?, claim_token_hash = NULL, claim_expires_at = NULL, updated_at = NOW() WHERE id = ?`, [callbackRecord.errorCode, errorMessage, taskRow.id] ); }); await recomputePlanListedState(taskRow.plan_id); const plan = await getSelectionFissionPlanDetail(taskRow.plan_id); const item = plan.items.find((row) => Number(row.id) === Number(taskRow.id)); return { taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, status: '上架失败', statusCode: 'failed', errorCode: item.listingErrorCode, errorMessage: item.listingErrorMessage, failedAt: item.updatedAt, saved: true, }; } async function listSelectionFissionCallbackHistory(planId, query = {}) { await ensureSelectionFissionSchema(); const normalizedPlanId = normalizePositiveInteger(planId, 0); if (!normalizedPlanId) { const error = new Error('缺少自动上架任务ID'); error.status = 400; throw error; } const page = normalizePositiveInteger(query.page, 1); const pageSize = normalizePositiveInteger(query.pageSize || query.page_size, 20, 100); const offset = (page - 1) * pageSize; const [countRows, rows] = await Promise.all([ planningDatabase.query( `SELECT COUNT(*) AS total FROM ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} WHERE plan_id = ?`, [normalizedPlanId] ), planningDatabase.query( `SELECT * FROM ${escapeIdentifier(CALLBACK_HISTORY_TABLE)} WHERE plan_id = ? ORDER BY received_at DESC, id DESC LIMIT ? OFFSET ?`, [normalizedPlanId, pageSize, offset] ), ]); return { total: Number(countRows[0]?.total || 0), page, pageSize, rows: rows.map((row) => ({ id: Number(row.id), schemeId: Number(row.plan_id), taskId: Number(row.plan_item_id || row.plan_id), taskCode: normalizeText(row.task_code, 64), callbackType: normalizeText(row.callback_type, 20), productId: normalizeText(row.product_id, 160), errorCode: normalizeText(row.error_code, 120), errorMessage: normalizeText(row.error_message, 1000), dingtalkUserName: normalizeText(row.dingtalk_user_name, 120), workerId: normalizeText(row.worker_id, 160), apiKeyName: normalizeText(row.api_key_name, 120), remoteIp: normalizeText(row.remote_ip, 80), receivedAt: row.received_at || null, })), }; } function getDeliveryVideoItems(item = {}) { const payload = item.videoAssets; if (!payload || typeof payload !== 'object') return []; return Array.isArray(payload.items) ? payload.items.filter(Boolean) : []; } function buildDeliveryVideoPayload(item = {}, videoAssets = item.videoAssets) { const deliveryItem = { ...item, videoAssets }; const warning = normalizeText(videoAssets?.warning, 1000); return { taskId: item.id, taskCode: item.taskCode, slotNo: item.slotNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, videos: getDeliveryVideoItems(deliveryItem).map(mapDeliveryVideo), warning: warning || null, }; } async function hydrateDeliveryVideoAssets(plan = {}) { const listingTasks = Array.isArray(plan.listingTasks) ? plan.listingTasks : (Array.isArray(plan.items) ? plan.items.filter((item) => item.selectedSpuCode) : []); const videoAssetEntries = []; for (const batch of splitIntoBatches(listingTasks, 5)) { videoAssetEntries.push(...await Promise.all(batch.map(async (item) => [ Number(item.id), await fetchVideoAssets(item.selectedSpuCode), ]))); } const videoAssetsByItemId = new Map(videoAssetEntries); const attachVideoAssets = (item) => ( videoAssetsByItemId.has(Number(item.id)) ? { ...item, videoAssets: videoAssetsByItemId.get(Number(item.id)) } : item ); return { ...plan, items: Array.isArray(plan.items) ? plan.items.map(attachVideoAssets) : [], listingTasks: listingTasks.map(attachVideoAssets), }; } function buildDeliveryReadiness(plan = {}, options = {}) { const items = Array.isArray(plan.items) ? plan.items : []; const sourceItems = Array.isArray(plan.sourceItems) ? plan.sourceItems : Array.from(new Map(items.map((item) => [ Number(item.sourceItemId || item.id), item, ])).values()); const listingTasks = Array.isArray(plan.listingTasks) ? plan.listingTasks : items.filter((item) => item.selectedSpuCode); const isAlibaba1688Pending = plan.workflowProfile === ALIBABA_1688_PENDING_PROFILE || listingTasks.some((item) => getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE); const missing = []; if (!plan.schemeCode) { missing.push({ scope: 'plan', field: 'schemeCode', message: '缺少方案编码' }); } if (options.requireSubmitted && !listingTasks.some((item) => item.listingStatus !== 'draft')) { missing.push({ scope: 'plan', field: 'listingStatus', message: '尚未发起自动上架' }); } if (sourceItems.length < 1 || sourceItems.length > 5) { missing.push({ scope: 'plan', field: 'items', message: '方案必须包含1~5个市场ID' }); } if (listingTasks.length === 0) { missing.push({ scope: 'plan', field: 'listingTasks', message: isAlibaba1688Pending ? '尚未将1688商品加入上架处理流程' : '尚未选择公司SPU并生成上架子任务', }); } for (const item of listingTasks) { const itemLabel = item.marketItemId || item.id; const itemIsAlibaba1688Pending = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const skuRows = Array.isArray(item.skuSnapshot?.rows) ? item.skuSnapshot.rows : []; const selectedSkuRows = getWorkflowSelectedSkuRows(item, skuRows); const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows); const activityPricingRows = filterWorkflowRowsBySelectedSkus(item, item.activityPricingRows); const finalPriceRows = itemIsAlibaba1688Pending ? pricingRows : activityPricingRows; if (!item.mainImageUrl) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'marketImage', message: '缺少市场主图第一张' }); } if (!itemIsAlibaba1688Pending && !item.replacedImageUrl) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'replacedImage', message: '缺少去文字图标并替换公司商品后的主图' }); } if (!item.selectedSpuCode) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'selectedSpuCode', message: itemIsAlibaba1688Pending ? '缺少1688源商品ID' : '缺少用户确认的公司SPU', }); } if (skuRows.length === 0) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'skuSnapshot', message: '缺少SPU下SKU明细' }); } if (!item.selectedMainSkuCode || !item.selectedMainSkuImageUrl) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'selectedMainSku', message: itemIsAlibaba1688Pending ? '缺少1688源商品的主图生成参考素材' : '缺少用户选择的主图展示SKU白底图', }); } if (!item.selectedProductTitle || item.optimizedTitleCandidates?.length !== 5) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'optimizedTitle', message: '缺少AI生成的5个商品标题或未选择最终标题' }); } if (selectedSkuRows.length === 0 || pricingRows.length !== selectedSkuRows.length) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'pricingRows', message: '缺少SKU定价明细' }); } if (!itemIsAlibaba1688Pending && (selectedSkuRows.length === 0 || activityPricingRows.length !== selectedSkuRows.length)) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'activityPricingRows', message: '缺少拼多多活动定价明细' }); } if (!item.pricingTableSavedAt) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'pricingTableSavedAt', message: '利润测算定价表尚未由用户确认保存' }); } if (finalPriceRows.length > 0 && finalPriceRows.some( (row) => !Number.isFinite(Number(row.finalGroupPrice ?? row.groupPrice)) || Number(row.finalGroupPrice ?? row.groupPrice) <= 0 )) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'groupPrice', message: '存在未设置拼单价的SKU' }); } const selectedSkuCodes = new Set( selectedSkuRows.map((row) => normalizeSkuCode(row.skuCode)).filter(Boolean) ); const skuNameRows = Array.isArray(item.skuNameCandidates?.rows) ? item.skuNameCandidates.rows.filter((row) => ( selectedSkuCodes.has(normalizeSkuCode(row.skuCode)) )) : []; const skuNamesComplete = itemIsAlibaba1688Pending ? selectedSkuRows.length > 0 && skuNameRows.length === selectedSkuRows.length && skuNameRows.every((row) => ( Array.isArray(row.names) && row.names.length === 5 && row.selectedName )) : pricingRows.length > 0 && pricingRows.every((row) => row.selectedSkuName); if (!skuNamesComplete) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'optimizedSkuNames', message: '缺少AI生成的5个SKU候选名称或未选择上架名称' }); } const expectedSkuImageCount = selectedSkuRows.length; const verifiedSkuImageCount = selectedSkuRows.filter((row) => ( getVerifiedSkuImageRow(item, row.skuCode) )).length; if (verifiedSkuImageCount < expectedSkuImageCount) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: 'skuImages', message: `已验证SKU图不足${expectedSkuImageCount}张`, }); } if (itemIsAlibaba1688Pending) { const compliance = buildAlibaba1688ListingCompliance(item, plan); for (const blocker of compliance?.blockers || []) { const exists = missing.some((entry) => ( Number(entry.itemId) === Number(item.id) && entry.field === blocker.field )); if (!exists) { missing.push({ scope: 'item', itemId: item.id, marketItemId: itemLabel, field: blocker.field, message: blocker.message, }); } } } } const complianceItems = listingTasks .map((item) => buildAlibaba1688ListingCompliance(item, plan)) .filter(Boolean); return { complete: missing.length === 0, missing, compliance: complianceItems.length > 0 ? { complete: complianceItems.every((item) => item.complete), items: complianceItems, } : null, }; } function mapDeliveryVideo(video = {}) { return { materialId: normalizeText(video.materialId || video.material_id), productModel: normalizeText(video.productModel || video.product_model), materialType: normalizeText(video.materialType || video.material_type), categoryName: normalizeText(video.categoryName || video.category_name), sceneName: normalizeText(video.sceneName || video.scene_name), fileName: normalizeText(video.fileName || video.file_name), fileSize: video.fileSize ?? video.file_size ?? null, duration: video.duration ?? null, coverUrl: normalizeImageUrl(video.coverUrl || video.cover_url), previewUrl: normalizeText(video.previewUrl || video.preview_url, 2000), previewClipUrl: normalizeText(video.previewClipUrl || video.preview_clip_url, 2000), downloadUrl: normalizeText(video.downloadUrl || video.download_url, 2000), uploadedAt: video.uploadedAt || video.uploaded_at || null, }; } function mapDeliveryProductCodeFields(row = {}, useInternalProductCode = false) { const sourceSkuCode = normalizeText(row.sourceSkuCode || row.skuCode || row.sku, 160); const internalProductCode = useInternalProductCode ? normalizeText(row.internalProductCode || row.internal_product_code, 160) : ''; return { sourceSkuCode, internalProductCode, productCodeProductName: normalizeText( row.productCodeProductName || row.product_code_product_name, 255 ), productCodeColorSpec: normalizeText( row.productCodeColorSpec || row.product_code_color_spec, 500 ), // productCode matches the public encoding API; listingSkuCode is the code // that downstream publishers should send to the marketplace. productCode: internalProductCode, listingSkuCode: internalProductCode || sourceSkuCode, }; } function normalizeDeliverySkuSpecs(row = {}) { const rawSpecs = [ row.specs, row.rawPayload?.sku?.specs, row.raw?.specs, ].find((value) => Array.isArray(value) && value.length > 0) || []; const specs = rawSpecs.map((entry) => { if (typeof entry === 'string') { const separator = entry.includes(':') ? ':' : entry.includes(':') ? ':' : ''; if (!separator) return null; const [name, ...valueParts] = entry.split(separator); return { name: normalizeText(name, 80), value: normalizeText(valueParts.join(separator), 160), }; } return { name: normalizeText(entry?.name || entry?.propertyName || entry?.attributeName, 80), value: normalizeText(entry?.value || entry?.propertyValue || entry?.specValue, 160), }; }).filter((entry) => entry?.name && entry?.value); if (specs.length === 0) { const color = normalizeText(row.color || row.colorName || row.color_label, 160); const size = normalizeText(row.size || row.sizeLabel || row.size_label, 255); if (color) specs.push({ name: '颜色', value: color }); if (size) specs.push({ name: '尺寸', value: size }); } const uniqueNames = new Set(); return specs.filter((entry) => { const key = entry.name.toLowerCase(); if (uniqueNames.has(key)) return false; uniqueNames.add(key); return true; }).slice(0, 2); } async function getSelectionFissionProfitPricingContext(planId, itemId) { return buildSelectionFissionProfitPricingContext( await getSelectionFissionPlanDetail(planId), itemId ); } function mapDeliveryPricingRow(row = {}, useInternalProductCode = false) { const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); return { sku: productCodeFields.listingSkuCode, skuCode: productCodeFields.listingSkuCode, ...productCodeFields, image: row.generatedImageUrl || row.skuImageUrl || '', skuImageUrl: row.skuImageUrl || '', generatedImageUrl: row.generatedImageUrl || '', cost: row.cost, skuName: row.selectedSkuName || row.skuName, originalSkuName: row.originalSkuName || row.skuName, optimizedSkuNames: Array.isArray(row.optimizedSkuNames) ? row.optimizedSkuNames : [], selectedSkuName: row.selectedSkuName || row.skuName, specs: normalizeDeliverySkuSpecs(row), aiSkuNameModel: row.aiSkuNameModel, aiSkuNameGeneratedAt: row.aiSkuNameGeneratedAt, productSource: row.productSource, operatorName: row.operatorName, operatedAt: row.operatedAt, brandName: row.brandName, barcode: row.barcode, size: row.size, color: row.color, freightNational: row.freightNational, freightZone1: row.freightZone1, freightZone2: row.freightZone2, freightZone3: row.freightZone3, freightZone4: row.freightZone4, discountAmount: row.discountAmount, activityDiscount: row.activityDiscount, companyAftersaleRatio: row.aftersaleRatio, promotionRatio: row.promotionRatio, competitorPrice: row.competitorPrice, installationFee: row.installationFee, grossMargin: row.grossMargin, platformCommissionRatio: row.platformCommissionRatio, marketingAdRatio: row.marketingAdRatio, farmingRatio: row.farmingRatio, insuranceRatio: row.insuranceRatio, brandUsageRatio: row.brandUsageRatio, estimatedFeeRatio: row.estimatedFeeRatio, finalPrice: row.finalPrice, groupPrice: row.groupPrice, finalSkuPrice: row.groupPrice, finalSkuPriceSource: 'profitPricingTable.groupPrice', grossProfit: row.grossProfit, operatingProfit: row.operatingProfit, operatingProfitRatio: row.operatingProfitRatio, minimumPrice: row.minimumPrice, listedItemId: row.listedItemId, }; } function mapDeliveryActivityPricingRow(row = {}, productCodeRow = row, useInternalProductCode = false) { const productCodeFields = mapDeliveryProductCodeFields( { ...row, ...productCodeRow }, useInternalProductCode ); return { sku: productCodeFields.listingSkuCode, skuCode: productCodeFields.listingSkuCode, ...productCodeFields, productSource: row.productSource, productImageUrl: row.productImageUrl, operatorName: row.operatorName, brandName: row.brandName, operatedAt: row.operatedAt, barcode: row.barcode, size: row.size, color: row.color, competitorPrice: row.competitorPrice, landedPrice: row.landedPrice, couponAmount: row.couponAmount, activityDiscount: row.activityDiscount, groupPrice: row.groupPrice, finalGroupPrice: row.finalGroupPrice, finalSkuPrice: row.finalGroupPrice ?? row.groupPrice, finalSkuPriceSource: 'profitPricingTable.groupPrice', listedItemId: row.listedItemId, activityPrices: row.activityPrices, }; } function buildDeliverySkuProductCodes(item = {}) { const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; return filterWorkflowRowsBySelectedSkus(item, item.pricingRows).map((row) => { const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); return { skuCode: productCodeFields.listingSkuCode, ...productCodeFields, }; }); } function buildDeliveryPayload(plan = {}) { const readiness = buildDeliveryReadiness(plan); const items = Array.isArray(plan.items) ? plan.items : []; const sourceItems = Array.isArray(plan.sourceItems) ? plan.sourceItems : items; const listingTasks = Array.isArray(plan.listingTasks) ? plan.listingTasks : items.filter((item) => item.selectedSpuCode); return { taskId: plan.id, taskCode: plan.schemeCode, schemeId: plan.id, schemeCode: plan.schemeCode, taskStatus: plan.status, status: plan.autoListingStatus, statusCode: plan.autoListingStatusCode, autoListedProductId: plan.autoListedProductId, autoListingCompletedAt: plan.autoListingCompletedAt, autoListingFailedAt: plan.autoListingFailedAt, autoListingErrorCode: plan.autoListingErrorCode, autoListingErrorMessage: plan.autoListingErrorMessage, dingtalkUserName: plan.dingtalkUserName, dingtalkUsername: plan.dingtalkUsername, departmentName: plan.departmentName, listingShopId: plan.listingShopId, listingShopCode: plan.listingShopCode, listingShopName: plan.listingShopName, listingShopPlatform: plan.listingShopPlatform, listingShop: plan.listingShop, claimedBy: plan.claimedBy, claimedAt: plan.claimedAt, claimExpiresAt: plan.claimExpiresAt, generatedAt: new Date().toISOString(), updatedAt: plan.updatedAt, workflow: plan.workflow || buildWorkflowSummary(plan), readiness, marketImages: (listingTasks.length > 0 ? listingTasks : sourceItems).map((item) => { const generatedMainImageUrls = (Array.isArray(item.mainImageGeneration?.rows) ? item.mainImageGeneration.rows : []) .map((row) => normalizeImageUrl(row?.imageUrl)) .filter(Boolean); return { taskId: item.id, taskCode: item.taskCode, slotNo: item.slotNo, variantNo: item.variantNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], channelName: item.channelName, title: item.title, shopName: item.shopName, imageUrl: generatedMainImageUrls[0] || item.replacedImageUrl || item.mainImageUrl, marketMainImageUrl: item.mainImageUrl, replacedImageUrl: item.replacedImageUrl, generatedMainImageUrls, watermarkReplaced: Boolean(item.replacedImageUrl), }; }), selectedSpus: listingTasks.map((item) => ({ taskId: item.id, taskCode: item.taskCode, slotNo: item.slotNo, variantNo: item.variantNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, spuName: item.selectedSpuName, selectedCandidate: item.selectedSpuPayload, selectedMainSkuCode: item.selectedMainSkuCode, selectedMainSkuName: item.selectedMainSkuName, selectedMainSkuImageUrl: item.selectedMainSkuImageUrl, })), productTitles: listingTasks.map((item) => ({ taskId: item.id, taskCode: item.taskCode, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, originalTitle: item.title, keywordStrategy: item.titleKeywordStrategy, optimizedTitles: item.optimizedTitleCandidates, selectedTitle: item.selectedProductTitle, aiModel: item.titleAiModel, generatedAt: item.titleAiGeneratedAt, })), skuImageSets: listingTasks.map((item) => { const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const pricingRows = filterWorkflowRowsBySelectedSkus(item, item.pricingRows); const rows = pricingRows.length > 0 ? pricingRows : getWorkflowSelectedSkuRows(item); return { taskId: item.id, taskCode: item.taskCode, slotNo: item.slotNo, variantNo: item.variantNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, skus: rows.map((row) => { const productCodeFields = mapDeliveryProductCodeFields(row, useInternalProductCode); const verifiedImageRow = getVerifiedSkuImageRow(item, row.skuCode); const sourceSkuCode = normalizeSkuCode(row.skuCode); const sourceSkuName = normalizeText( verifiedImageRow?.generatedForSkuName || row.originalSkuName || row.skuName, 255 ); const sourceSkuImageUrl = normalizeImageUrl( verifiedImageRow?.generatedFromImageUrl || row.skuImageUrl ); const generatedImageUrl = normalizeImageUrl(verifiedImageRow?.generatedImageUrl); return { skuCode: productCodeFields.listingSkuCode, ...productCodeFields, sourceSkuCode, sourceSkuName, sourceSkuImageUrl, generationMode: verifiedImageRow?.generationMode || '', skuName: row.selectedSkuName || row.skuName, originalSkuName: row.originalSkuName || row.skuName, optimizedSkuNames: Array.isArray(row.optimizedSkuNames) ? row.optimizedSkuNames : [], selectedSkuName: row.selectedSkuName || row.skuName, specs: normalizeDeliverySkuSpecs(row), aiSkuNameModel: row.aiSkuNameModel || '', aiSkuNameGeneratedAt: row.aiSkuNameGeneratedAt || null, skuImageUrl: sourceSkuImageUrl, generatedImageUrl, imageUrl: generatedImageUrl, }; }), }; }), mainVideos: listingTasks.map((item) => buildDeliveryVideoPayload(item)), pricingTables: listingTasks.map((item) => { const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; return { taskId: item.id, taskCode: item.taskCode, tableType: 'base', tableName: '基础成本利润定价表', slotNo: item.slotNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, listedItemId: item.listedItemId, pricingTableSavedAt: item.pricingTableSavedAt, pricingTableSavedBy: item.pricingTableSavedBy, pricingTableVersion: item.pricingTableVersion, rows: filterWorkflowRowsBySelectedSkus(item, item.pricingRows) .map((row) => mapDeliveryPricingRow(row, useInternalProductCode)), }; }), activityPricingTables: listingTasks .filter((item) => getSelectionWorkflowProfile(item) !== ALIBABA_1688_PENDING_PROFILE) .map((item) => { const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const pricingRowsBySku = new Map( filterWorkflowRowsBySelectedSkus(item, item.pricingRows).map((row) => [ normalizeSkuCode(row.skuCode), row, ]) ); return { taskId: item.id, taskCode: item.taskCode, tableType: 'pdd_activity', tableName: '拼多多活动定价表', slotNo: item.slotNo, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], spuCode: item.selectedSpuCode, listedItemId: item.listedItemId, pricingTableSavedAt: item.pricingTableSavedAt, pricingTableVersion: item.pricingTableVersion, activityColumns: PDD_ACTIVITY_PRICE_DEFINITIONS, rows: filterWorkflowRowsBySelectedSkus(item, item.activityPricingRows) .map((row) => mapDeliveryActivityPricingRow( row, pricingRowsBySku.get(normalizeSkuCode(row.skuCode)), useInternalProductCode )), }; }), automaticListingTasks: listingTasks.map((item) => ({ taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], slotNo: item.slotNo, variantNo: item.variantNo, spuCode: item.selectedSpuCode, spuName: item.selectedSpuName, productTitle: item.selectedProductTitle, titleKeywordStrategy: item.titleKeywordStrategy, productCodeStyleCode: item.productCodeStyleCode, skuProductCodes: buildDeliverySkuProductCodes(item), listingStatus: item.listingStatus, listedProductId: item.listedItemId, listingErrorCode: item.listingErrorCode, listingErrorMessage: item.listingErrorMessage, })), }; } function buildDeliveryTaskPayload(plan, item, options = {}) { const packagePayload = buildDeliveryPayload(plan); const matchTask = (row) => Number(row.taskId) === Number(item.id); const automaticListingTask = packagePayload.automaticListingTasks.find(matchTask) || null; const marketImage = packagePayload.marketImages.find(matchTask) || null; const selectedSpu = packagePayload.selectedSpus.find(matchTask) || null; const productTitle = packagePayload.productTitles.find(matchTask) || null; const skuImageSet = packagePayload.skuImageSets.find(matchTask) || null; const mainVideo = Object.prototype.hasOwnProperty.call(options, 'videoAssets') ? buildDeliveryVideoPayload(item, options.videoAssets) : packagePayload.mainVideos.find(matchTask) || null; const useInternalProductCode = getSelectionWorkflowProfile(item) === ALIBABA_1688_PENDING_PROFILE; const pricingTable = packagePayload.pricingTables.find(matchTask) || null; const activityPricingTable = packagePayload.activityPricingTables.find(matchTask) || null; const finalPriceRows = useInternalProductCode ? (pricingTable?.rows || []) : (activityPricingTable?.rows || []); return { taskId: item.id, taskCode: item.taskCode, schemeId: plan.id, schemeCode: plan.schemeCode, dingtalkUserName: plan.dingtalkUserName, departmentName: plan.departmentName, listingShopId: plan.listingShopId, listingShopCode: plan.listingShopCode, listingShopName: plan.listingShopName, listingShopPlatform: plan.listingShopPlatform, listingShop: plan.listingShop, marketItemId: item.marketItemId, priceBand: item.priceBand, priceBandList: item.priceBandList || [], slotNo: item.slotNo, variantNo: item.variantNo, spuCode: item.selectedSpuCode, spuName: item.selectedSpuName, productTitle: item.selectedProductTitle, titleKeywordStrategy: item.titleKeywordStrategy, productCodeStyleCode: item.productCodeStyleCode, workflowProfile: getSelectionWorkflowProfile(item), skuProductCodes: automaticListingTask?.skuProductCodes || [], automaticListingTask, marketImage, selectedSpu, productTitleDetail: productTitle, skuImageSet, mainVideo, pricingTable, activityPricingTable, // Installed desktop clients consume the plan-level array contract when // expanding a claimed child task. Keep the singular fields above for the // external child-task API, and expose child-scoped arrays for compatibility. marketImages: marketImage ? [marketImage] : [], selectedSpus: selectedSpu ? [selectedSpu] : [], productTitles: productTitle ? [productTitle] : [], skuImageSets: skuImageSet ? [skuImageSet] : [], mainVideos: mainVideo ? [mainVideo] : [], pricingTables: pricingTable ? [pricingTable] : [], activityPricingTables: activityPricingTable ? [activityPricingTable] : [], automaticListingTasks: automaticListingTask ? [automaticListingTask] : [], materials: Array.isArray(options.materials) ? options.materials : [], pricingTableStatus: { saved: Boolean(item.pricingTableSavedAt), savedAt: item.pricingTableSavedAt, savedBy: item.pricingTableSavedBy, version: item.pricingTableVersion, listedItemId: item.listedItemId, }, skuFinalPrices: finalPriceRows.map((row) => ({ skuCode: row.skuCode, ...mapDeliveryProductCodeFields(row, useInternalProductCode), finalSkuPrice: row.finalSkuPrice, finalSkuPriceSource: row.finalSkuPriceSource || 'profitPricingTable.groupPrice', priceField: 'groupPrice', })), readiness: buildDeliveryReadiness({ ...plan, items: [item], sourceItems: plan.sourceItems, listingTasks: [item], }), generatedAt: new Date().toISOString(), }; } async function getSelectionFissionPlanByExternalIdentity(identity, dingtalkUserName) { await ensureSelectionFissionSchema(); const normalizedIdentity = normalizeText(identity, 160); const normalizedUserName = normalizeText(dingtalkUserName, 120); if (!normalizedIdentity) { const error = new Error('缺少方案ID或方案编码'); error.status = 400; throw error; } if (!normalizedUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const planId = /^\d+$/.test(normalizedIdentity) ? Number(normalizedIdentity) : 0; const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PLAN_TABLE)} WHERE (id = ? OR scheme_code = ?) AND ( TRIM(COALESCE(dingtalk_user_name, '')) = ? OR TRIM(COALESCE(dingtalk_username, '')) = ? ) LIMIT 1`, [planId, normalizedIdentity, normalizedUserName, normalizedUserName] ); if (!rows[0]) { const error = new Error('未找到该钉钉用户对应的选品方案'); error.status = 404; throw error; } const [plan] = await attachPlanChildren([mapPlan(rows[0])]); return plan; } async function getSelectionFissionDeliveryPackage(input = {}) { const plan = await getSelectionFissionPlanByExternalIdentity( input.schemeCode || input.schemeId || input.planId || input.identity, input.dingtalkUserName || input.dingtalkUsername || input.userName ); const readiness = buildDeliveryReadiness(plan, { requireSubmitted: true }); if (!readiness.complete && !input.includeIncomplete) { const error = new Error('该选品方案数据尚未补充完整,暂不可交付'); error.status = 409; error.data = { schemeId: plan.id, schemeCode: plan.schemeCode, readiness, }; throw error; } const deliveryPlan = await hydrateDeliveryVideoAssets(plan); const payload = buildDeliveryPayload(deliveryPlan); return payload; } async function listSelectionFissionDeliveryPlans(input = {}) { await ensureSelectionFissionSchema(); const normalizedUserName = normalizeText(input.dingtalkUserName || input.dingtalkUsername || input.userName, 120); if (!normalizedUserName) { const error = new Error('缺少钉钉用户名'); error.status = 400; throw error; } const page = normalizePositiveInteger(input.page, 1); const pageSize = normalizePositiveInteger(input.pageSize || input.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); const rows = await planningDatabase.query( `SELECT * FROM ${escapeIdentifier(PLAN_TABLE)} WHERE TRIM(COALESCE(dingtalk_user_name, '')) = ? OR TRIM(COALESCE(dingtalk_username, '')) = ? ORDER BY updated_at DESC, id DESC LIMIT 500`, [normalizedUserName, normalizedUserName] ); const plans = await attachPlanChildren(rows.map(mapPlan)); const includeIncomplete = Boolean(input.includeIncomplete || input.include_incomplete); const deliverablePlans = plans .map((plan) => ({ id: plan.id, taskId: plan.id, taskCode: plan.schemeCode, schemeId: plan.id, schemeCode: plan.schemeCode, taskStatus: plan.status, status: plan.autoListingStatus, statusCode: plan.autoListingStatusCode, autoListedProductId: plan.autoListedProductId, autoListingCompletedAt: plan.autoListingCompletedAt, autoListingFailedAt: plan.autoListingFailedAt, autoListingErrorCode: plan.autoListingErrorCode, autoListingErrorMessage: plan.autoListingErrorMessage, dingtalkUserName: plan.dingtalkUserName, departmentName: plan.departmentName, listingShopId: plan.listingShopId, listingShopCode: plan.listingShopCode, listingShopName: plan.listingShopName, listingShopPlatform: plan.listingShopPlatform, listingShop: plan.listingShop, claimedBy: plan.claimedBy, claimedAt: plan.claimedAt, claimExpiresAt: plan.claimExpiresAt, itemCount: plan.itemCount, listingTaskCount: plan.listingTaskCount, listedItemCount: plan.listedItemCount, listedProductIds: plan.autoListedProductIds, workflow: plan.workflow, readiness: buildDeliveryReadiness(plan, { requireSubmitted: true }), updatedAt: plan.updatedAt, createdAt: plan.createdAt, })) .filter((plan) => includeIncomplete || plan.readiness.complete); const start = (page - 1) * pageSize; const pageRows = deliverablePlans.slice(start, start + pageSize); return { total: deliverablePlans.length, page, pageSize, hasMore: start + pageRows.length < deliverablePlans.length, rows: pageRows, }; } module.exports = { claimSelectionFissionDeliveryTask, createSelectionFissionPlan, deleteSelectionFissionSourceItem, confirmSelectionFissionMainImageReview, confirmSelectionFissionSpu, fetchFreightProfile, generateSelectionFissionPricingTable, getSelectionFissionProfitPricingContext, getSelectionFissionProductCodeMeta, getCompanySkusBySpu, generateSelectionFissionSkuNames, getSelectionFissionDeliveryPackage, getSelectionFissionItemSkus, getSelectionFissionSkuMaterialAssets, getSelectionFissionPlanDetail, getSelectionFissionPddAttributePreparation, getSelectionFissionPddDraftMeta, listSelectionFissionCallbackHistory, listSelectionFissionRevisionHistory, listSelectionFissionPlans, warmSelectionFissionReadSchema: ensureSelectionFissionReadSchema, listSelectionFissionDeliveryPlans, listSelectionFissionExecutionOptions, listSelectionFissionPddPublishingShops, listSelectionFissionUserShops, receiveSelectionFissionAutoListingFailure, receiveSelectionFissionAutoListingResult, requeueSelectionFissionAutoListing, replaceSelectionFissionSupplySource, saveSelectionFissionAutoListing, saveSelectionFissionProfitPricingTable, searchSelectionFissionMarketReferences, searchSelectionFissionAlibaba1688Products, searchSelectionFissionSimilarProducts, confirmSelectionFissionMarketReferences, selectSelectionFissionMainSku, confirmSelectionFissionSkuImageReview, selectSelectionFissionSkuMaterialAsset, startSelectionFissionItemWorkflow, startSelectionFissionPddAttributePreparation, startSelectionFissionWorkflow, syncSelectionFissionUserShops, updateSelectionFissionItemListing, updateSelectionFissionPlanStatus, updateSelectionFissionProductTitle, updateSelectionFissionSkuName, _test: { COMPANY_TASK_WORKFLOW_STEPS, PDD_ACTIVITY_PRICE_DEFINITIONS, WORKFLOW_STEP_DEFINITIONS, advanceWorkflowProgress, buildDeliveryPayload, buildDeliveryReadiness, buildDeliveryTaskPayload, buildDeliveryVideoPayload, buildSelectionSupplyMetadata, buildSelectionSupplyDecision, buildEmptyPddActivityPrices, buildMarketProductDetailUrl, buildMarketReferencePriceBand, buildPreferredMarketReferenceSeed, buildAlibaba1688SkuSnapshot, resolveAlibaba1688GlobalEncoding, applyAlibaba1688GlobalEncodingToSnapshot, buildAlibaba1688GlobalEncodingState, buildAlibaba1688SourceReadiness, buildAlibaba1688PricingEvidence, buildAlibaba1688ListingCompliance, getPddDraftItemConfig, assertAlibaba1688PddDraftPlan, buildSkuSelectionAssetsFromSnapshot, getSkuSelectionImage, getMainGenerationSourceImage, getSimilarProductsConfig, getSimilarProductsRequestHeaders, createSimilarProductsRequestError, isRetryableSimilarProductsError, isSimilarProductsFallbackEligible, isRetryableAlibaba1688ImageSearchFailure, getRelistableSelectionTasks, getWorkflowSelectedSkuCodes, getWorkflowSelectedSkuRows, buildBulkSkuNameSelections, getMaxMainImageCount, getConfiguredMainImageCount, isSelectionFissionSpuCurrent, filterWorkflowRowsBySelectedSkus, getWorkflowProductTitleOptions, getCurrentTitleKeywordStrategy, getTaskForbiddenBrandTerms, findForbiddenTitleBrandTerm, extractReferenceBrandTerms, validateAdditionalProductTitleCandidates, prepareSkuSceneImageRows, getVerifiedSkuImageRow, normalizeSkuImageMode, groupSkuSceneRowsBySourceImage, splitIntoBatches, summarizeSkuSceneImageRows, summarizeSkuSceneImageRequests, finalizeSourceSkuImageRows, resetInterruptedSkuSceneImageState, mergeSelectedSkuRowsWithLegacySnapshot, batchUpdateSelectionPricingRows, insertRowsInBatches, normalizeSkuCode, normalizeSqlBindValues, resolveMinimumPrice, resolvePreservedSkuNameSelection, buildWorkflowSummary, buildSelectionFissionPlanListSummary, fetchCompanySkusFromJstApi, enrichCompanySpuCandidates, hydrateCompanySpuCandidateDetails, fillMonthlySalesTrend6m, formatImageAspectRatio, hydrateDeliveryVideoAssets, buildDeliveryMaterials, getSelectionFissionItemNodeState, getPreviousCompanyTaskWorkflowStep, hashClaimToken, normalizeClaimToken, normalizeWhiteBackgroundSkuAssetDetails, assertValidClaimToken, mapJstCompanySkuRow, mapActivityPricingRow, mapPlan, mapPricingRow, normalizeAutoListingProductId, normalizeProfitPricingRows, normalizeProductCodeMeta, normalizeProductCodeConfig, buildProductCodeRequest, buildProductCodeRequestHash, validateProductCodeResponse, pruneSelectionSkuPayload, normalizeListingShop, buildSelectionFissionExecutionOptions, canDelegateListingExecution, normalizeMarketItemLookupId, inferMarketChannelName, normalizePriceBandList, sortPriceBandValues, normalizeMarketReferenceCandidate, normalizeSelectionFissionPlanItems, normalizeSimilarCandidate, normalizeSimilarResponsePayload, collectMarketReferenceCandidates, normalizeSpuSelections, parseCompanySkuMarkdown, parseMarketReferenceMarkdown, parseMarketSalesVolume, cosineSimilarity, filterStrictMarketReferenceCandidates, hasObviousMarketCategoryConflict, rankSpuCandidatesByCategory, validateCompanySpuCandidates, getCompanyCategoryTerms, supplementCompanySpuCandidates, supplementMarketReferenceCandidates, requestSelectionGeneratedImage, generateMainReplacementImage, generateSkuSceneImage, buildRigidProductIdentityPrompt, detectImageContent, getExternalRequestErrorMessage, hasStandardImageExtension, shouldRehostImageGenerationReference, normalizeImageGenerationReferenceUrl, buildMainImageReferenceImages, normalizeMainImageRetryIndexes, parseImageDimensions, isSelectionWorkflowTaskStepComplete, collectTitleStrategyKeywords, enrichTitleKeywordStrategyWithEvidence, matchTitleKeywordEvidence, toMysqlDateTime, }, };