diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue index 59f1789e..dbcdee0c 100644 --- a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue @@ -166,7 +166,7 @@ import { } from '@/shared/api/java-modules' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { saveUrlWithProgress } from '@/shared/utils/download-progress' -import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard' +import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard' import { passGuard } from '@/shared/dispatch-guard-ui' import { formatDateTime } from '@/shared/utils/datetime' import { uploadPathsToJava } from '@/shared/utils/upload-to-java' @@ -349,11 +349,15 @@ async function submitConvertRun() { convertResultItems.value = result.items || [] await loadConvertHistory() if (result.total > 0 && result.successCount === 0) { + // 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜 + const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5) await passGuard( guardBlocked( '格式转换未成功', `本次提交的 ${result.total} 个文件全部转换失败。\n` + - '常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n' + + (details.length + ? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n` + : '常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n') + '请在右侧结果列表查看每个文件的失败原因,或换一个模板后重试。', 'convert.all-failed', ), diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue index 54ed2c0f..6a41eded 100644 --- a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue @@ -137,7 +137,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand' import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { saveUrlWithProgress } from '@/shared/utils/download-progress' -import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard' +import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard' import { passGuard } from '@/shared/dispatch-guard-ui' import { formatDateTime } from '@/shared/utils/datetime' import { uploadPathsToJava } from '@/shared/utils/upload-to-java' @@ -237,7 +237,8 @@ function clearAllCleanColumns() { async function loadCleanHeaders(fileKey: string) { const result = await getExcelInfo(fileKey) if (!result.headers?.length) { - ElMessage.error('读取 Excel 表头失败') + // 接口本身成功、只是没解析出表头,说明文件内容有问题而非「读取失败」,别让用户反复重选文件 + ElMessage.error('未读到表头行:请确认文件不是空表、且首行是表头') cleanAvailableColumns.value = [] cleanSelectedColumns.value = [] return @@ -359,11 +360,15 @@ async function submitCleanRun() { await loadCleanHistory() // 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功 if (result.total > 0 && result.successCount === 0) { + // 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜 + const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5) await passGuard( guardBlocked( '去重未成功', `本次提交的 ${result.total} 个文件全部处理失败。\n` + - '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' + + (details.length + ? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n` + : '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') + '请在右侧结果列表查看每个文件的失败原因后重试。', 'dedupe.all-failed', ), diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 0d614c8c..c426dc32 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -1105,7 +1105,13 @@ async function submitRun() { syncResultState() if (hasBlockedItems) { - ElMessage.warning('部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。') + // 具体原因(店铺未录入 / 索引未命中 / 表头错误等)在各文件项里,直接带出来; + // 只报「请稍后重试」会把「需去后台添加店铺」误导成「等一等就好」 + const firstBlocked = normalizedItems.find((item) => !isUsableMatchedItem(item)) + const detail = firstBlocked ? getDisplayError(firstBlocked) : '' + ElMessage.warning(detail + ? `部分文件不可启动:${detail}` + : '部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。') } else if (hasStaleMatchedItems) { ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。') } else if (hasRunnableItems) { diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index 8ba9181b..4e94c16e 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -511,6 +511,12 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) { if (row.matched) { return "已匹配成功,请查看状态确认"; } + if (row.matchStatus === "CONFLICT") { + return "存在多个同名店铺,请人工确认"; + } + if (row.matchStatus === "PENDING") { + return "店铺尚未匹配完成,请稍后查看"; + } return "未匹配成功,请检查店铺名"; } diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index 83f160c0..3887bc95 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -848,6 +848,8 @@ function formatMatchRemark(row: PriceTrackShopQueueItem) { if (msg) return msg if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务' if (row.matched) return '已关联店铺,请查看状态确认' + if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认' + if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看' return '未匹配成功,请检查店铺名' } diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue index 4eac2a6e..63950d1a 100644 --- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue @@ -1056,6 +1056,12 @@ function formatMatchRemark(row: ProductRiskShopQueueItem) { if (row.matched) { return '已关联店铺,请查看状态确认' } + if (row.matchStatus === 'CONFLICT') { + return '存在多个同名店铺,请人工确认' + } + if (row.matchStatus === 'PENDING') { + return '店铺尚未匹配完成,请稍后查看' + } return '未匹配成功,请检查店铺名' } diff --git a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue index 7394d77e..af43f105 100644 --- a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue @@ -216,6 +216,8 @@ import { checkParseResult, checkQueuePayload, checkSelectedFiles, + collectDistinctErrors, + guardBlocked, } from '@/shared/dispatch-guard' import { passGuard } from '@/shared/dispatch-guard-ui' import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version' @@ -563,10 +565,25 @@ async function submitRun() { sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value), }) if (!parsed.taskId) throw new Error('后端未返回有效任务标识') - // publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数 + const fileErrors = collectDistinctErrors( + (parsed.files || []).map((file) => file.errorMessage || file.error), + ) + // 店铺未录入后台是最高频的解析失败原因(2026-09-17 客户因此连试 7 次), + // 单独用「店铺未找到」弹窗直给后端原因,避免被通用文案淹没 + const missingShopErrors = fileErrors.filter((message) => message.includes('未找到店铺')) + if (!parsed.totalRows && missingShopErrors.length) { + await passGuard(guardBlocked('店铺未找到', missingShopErrors.join('\n'), 'publish.shop-missing')) + return + } + // publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数。 + // 文件级失败(店铺未匹配等)时 Java 不解析 Excel、totalRows 同样为 0,把具体 + // 原因一并交给 guard 优先展示,避免只报「没有读到任何数据行」误导排查方向。 const guard = checkParseResult( { taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows }, - { requiredColumnsHint: '店铺名 / 商品行' }, + { + requiredColumnsHint: '店铺名 / 商品行', + fileErrors, + }, ) if (!(await passGuard(guard))) return @@ -604,7 +621,9 @@ async function submitRun() { await Promise.all([loadDashboard(), loadHistory()]) if (!batch.pendingFileIds.length) { - queueMessage.value = '解析完成,当前没有匹配成功且可上架的文件。' + queueMessage.value = fileErrors.length + ? `解析完成,当前没有匹配成功且可上架的文件:${fileErrors[0]}` + : '解析完成,当前没有匹配成功且可上架的文件。' ElMessage.warning(queueMessage.value) return } diff --git a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue index fe6de5c1..a122ac6a 100644 --- a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue @@ -439,6 +439,12 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) { if (row.matched) { return "已匹配成功,请查看状态确认"; } + if (row.matchStatus === "CONFLICT") { + return "存在多个同名店铺,请人工确认"; + } + if (row.matchStatus === "PENDING") { + return "店铺尚未匹配完成,请稍后查看"; + } return "未匹配成功,请检查店铺名"; } diff --git a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue index 5c4ff9f5..537e45f4 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue @@ -565,7 +565,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) { } } function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }[value] || value || '—' } -function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; return '未匹配成功,请检查店铺名' } +function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'; if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'; return '未匹配成功,请检查店铺名' } async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() } function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) } async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 启动失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已提交,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺启动已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '启动失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } } diff --git a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue index d9269ecd..ccc3c88b 100644 --- a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue @@ -145,7 +145,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand' import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { saveUrlWithProgress } from '@/shared/utils/download-progress' -import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard' +import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard' import { passGuard } from '@/shared/dispatch-guard-ui' import { formatDateTime } from '@/shared/utils/datetime' import { uploadPathsToJava } from '@/shared/utils/upload-to-java' @@ -327,11 +327,15 @@ async function submitSplitRun() { splitResultItems.value = result.items || [] await loadSplitHistory() if (result.total > 0 && result.successCount === 0) { + // 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜 + const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5) await passGuard( guardBlocked( '拆分未成功', `本次提交的 ${result.total} 个文件全部处理失败。\n` + - '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' + + (details.length + ? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n` + : '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') + '请在右侧结果列表查看每个文件的失败原因后重试。', 'split.all-failed', ), diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue index 6d84728e..4b108711 100644 --- a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -570,6 +570,12 @@ function formatMatchRemark(row: WithdrawShopQueueItem) { if (row.matched) { return "已匹配成功,请查看状态确认"; } + if (row.matchStatus === "CONFLICT") { + return "存在多个同名店铺,请人工确认"; + } + if (row.matchStatus === "PENDING") { + return "店铺尚未匹配完成,请稍后查看"; + } return "未匹配成功,请检查店铺名"; } diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index 2b57c562..84ff0234 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -1015,12 +1015,12 @@ async function waitForImageVideoTask(ticket: ImageVideoAsyncTaskVo): Promise `· ${item}`).join('\n') + + (fileErrors.length > shown.length + ? `\n· 另有 ${fileErrors.length - shown.length} 条不同原因` + : '') + + '\n请按上述原因处理对应文件后重新上传解析。', + }) + } else if (!totalRows) { issues.push({ code: 'parse.empty-file', severity: 'block', @@ -387,6 +408,20 @@ function normalizeCount(value: unknown): number | null { return null } +/** 汇总一组错误原因:去空、去重(同一原因多条只留一条),保持出现顺序。 */ +export function collectDistinctErrors(values: readonly unknown[] | undefined): string[] { + if (!values || !values.length) return [] + const seen = new Set() + const result: string[] = [] + for (const value of values) { + const trimmed = typeof value === 'string' ? value.trim() : '' + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + result.push(trimmed) + } + return result +} + export interface QueuePayloadOptions { /** data 下必须存在且非空的字段名 */ requiredDataKeys?: readonly string[] diff --git a/frontend-vue/tests/dispatch-guard.test.ts b/frontend-vue/tests/dispatch-guard.test.ts index 082dc8b0..c4f14c2f 100644 --- a/frontend-vue/tests/dispatch-guard.test.ts +++ b/frontend-vue/tests/dispatch-guard.test.ts @@ -7,6 +7,7 @@ import { checkParseResult, checkQueuePayload, checkSelectedFiles, + collectDistinctErrors, extensionOf, findUnsafeJsonPaths, guardPassed, @@ -165,6 +166,55 @@ test('test_task_101_dispatch_guard_boundary_parse_result_zero_rows_blocked', () assert.match(emptyFile.message, /没有读到任何数据行/) }) +test('test_task_101_dispatch_guard_parse_result_file_errors_shown_instead_of_empty_file', () => { + // 2026-09-17:店铺未录入后台导致文件级失败,用户被「空文件」文案误导反复重传 + const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { + fileErrors: ['后台店铺管理中未找到店铺:林清斌,请先添加店铺信息'], + }) + assert.equal(result.ok, false) + assert.deepEqual(codes(result), ['parse.file-failed']) + assert.match(result.message, /林清斌/) + assert.ok(!result.message.includes('没有读到任何数据行'), '有具体原因时不展示通用空文件文案') +}) + +test('test_task_101_dispatch_guard_parse_result_file_errors_dedupe_blank_and_limit', () => { + const deduped = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { + fileErrors: ['店铺未匹配', ' ', '店铺未匹配', '', '第二条原因'], + }) + assert.deepEqual(codes(deduped), ['parse.file-failed']) + assert.equal( + deduped.message.split('\n').filter((line) => line.startsWith('· ')).length, + 2, + '空串与重复原因不应重复展示', + ) + + const many = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { + fileErrors: Array.from({ length: 10 }, (_, index) => `原因 ${index}`), + }) + assert.match(many.message, /另有 2 条不同原因/) +}) + +test('test_task_101_dispatch_guard_parse_result_blank_file_errors_fallback_to_empty_file', () => { + const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { + fileErrors: [' ', ''], + }) + assert.deepEqual(codes(result), ['parse.empty-file']) +}) + +test('test_task_101_dispatch_guard_collect_distinct_errors', () => { + assert.deepEqual(collectDistinctErrors(['a', ' ', 'a', '', 'b']), ['a', 'b'], '去空去重且保持顺序') + assert.deepEqual(collectDistinctErrors([' 店铺未找到 ']), ['店铺未找到'], '首尾空白裁剪') + assert.deepEqual(collectDistinctErrors([]), []) + assert.deepEqual(collectDistinctErrors(undefined), []) + assert.deepEqual(collectDistinctErrors([null, 7, undefined]), [], '非字符串项忽略') +}) + +test('test_task_101_dispatch_guard_parse_result_file_errors_ignored_when_rows_present', () => { + const result = checkParseResult(parseVo(), { fileErrors: ['某文件失败'] }) + assert.equal(result.ok, true) + assert.equal(result.needsConfirm, false) +}) + test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => { const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false }) assert.equal(result.ok, true)