From d9b1b980ca462b7e7caa43d2673893d24f62182e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 16:12:47 +0800 Subject: [PATCH] =?UTF-8?q?task-76(ASIN=20=E6=95=B0=E6=8D=AE=E4=B8=AD?= =?UTF-8?q?=E5=BF=83):=20=E5=AE=9E=E7=8E=B0=E6=9C=80=E4=BD=8E=E4=BB=B7=20A?= =?UTF-8?q?SIN=20=E5=AF=BC=E5=85=A5=E4=B8=8E=E5=88=A0=E9=99=A4=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/skip-price-asins/import、/delete-import(file+group_id 必填) 启动 + GET 进度轮询, 复用新增通用导入进度模型(含 deletedCount), 8 个契约测试。 --- .../src/pages/asin/import-progress-model.ts | 101 ++++++++++++++++++ .../src/pages/asin/skip-price-import-api.ts | 44 ++++++++ admin-frontend-vue/tests/task-76.test.ts | 85 +++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 admin-frontend-vue/src/pages/asin/import-progress-model.ts create mode 100644 admin-frontend-vue/src/pages/asin/skip-price-import-api.ts create mode 100644 admin-frontend-vue/tests/task-76.test.ts diff --git a/admin-frontend-vue/src/pages/asin/import-progress-model.ts b/admin-frontend-vue/src/pages/asin/import-progress-model.ts new file mode 100644 index 00000000..422c25cd --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/import-progress-model.ts @@ -0,0 +1,101 @@ +/** ASIN 中心导入/轮询通用模型(任务 76):启动结果、Excel 校验与导入进度归一(含 deletedCount),纯逻辑。 */ +import { unwrap } from '../../api/envelope.ts' + +export const EXCEL_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i + +/** 是否为允许导入的 Excel 文件。 */ +export function isAllowedExcelImportFile(fileName: unknown): boolean { + return typeof fileName === 'string' && EXCEL_IMPORT_ALLOWED_EXT.test(fileName.trim()) +} + +/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */ +export function parseImportStart(payload: unknown): string { + const data = unwrap(payload) + const record = data && typeof data === 'object' ? (data as Record) : null + const importId = typeof record?.importId === 'string' ? record.importId.trim() : '' + if (!importId) throw new Error('导入任务响应异常:缺少任务 ID') + return importId +} + +export type ImportStatus = 'pending' | 'running' | 'success' | 'failed' + +export interface ImportProgress { + status: ImportStatus + totalRows: number + processedRows: number + asinCount: number + insertedCount: number + deletedCount: number + skippedCount: number + errorMessage?: string +} + +function intOrZero(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0 +} + +/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */ +export function parseImportProgress(payload: unknown): ImportProgress { + const data = unwrap(payload) + const record = data && typeof data === 'object' ? (data as Record) : null + const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : '' + const status: ImportStatus = + rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending' + ? rawStatus + : 'pending' + const progress: ImportProgress = { + status, + totalRows: intOrZero(record?.totalRows), + processedRows: intOrZero(record?.processedRows), + asinCount: intOrZero(record?.asinCount), + insertedCount: intOrZero(record?.insertedCount), + deletedCount: intOrZero(record?.deletedCount), + skippedCount: intOrZero(record?.skippedCount), + } + const errorMessage = + typeof record?.errorMessage === 'string' && record.errorMessage.trim() + ? record.errorMessage.trim() + : undefined + if (errorMessage) progress.errorMessage = errorMessage + return progress +} + +/** 是否到达终态(success/failed)。 */ +export function importFinished(progress: Pick): boolean { + return progress.status === 'success' || progress.status === 'failed' +} + +/** 是否成功完成。 */ +export function importSucceeded(progress: Pick): boolean { + return progress.status === 'success' +} + +/** 导入添加结果文案。 */ +export function importOutcomeText(progress: ImportProgress): string { + switch (progress.status) { + case 'running': + return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行` + case 'success': + return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount} 条` + case 'failed': + return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试' + case 'pending': + default: + return '等待导入任务开始…' + } +} + +/** 导入删除结果文案。 */ +export function deleteOutcomeText(progress: ImportProgress): string { + switch (progress.status) { + case 'running': + return `删除处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行` + case 'success': + return `删除完成:删除 ${progress.deletedCount} 条,跳过 ${progress.skippedCount} 条` + case 'failed': + return progress.errorMessage ? `删除失败:${progress.errorMessage}` : '删除失败,请重试' + case 'pending': + default: + return '等待删除任务开始…' + } +} diff --git a/admin-frontend-vue/src/pages/asin/skip-price-import-api.ts b/admin-frontend-vue/src/pages/asin/skip-price-import-api.ts new file mode 100644 index 00000000..c4397fe2 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-import-api.ts @@ -0,0 +1,44 @@ +/** 最低价 ASIN 导入添加/删除适配(任务 76):multipart POST /import、/delete-import(file+group_id 必填) 及进度轮询。 */ +import { http } from '@/api/http' +import { parseImportProgress, parseImportStart, type ImportProgress } from './import-progress-model' + +export const SKIP_PRICE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/import' +export const SKIP_PRICE_DELETE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/delete-import' + +function requireGroupId(groupId: number): number { + if (typeof groupId !== 'number' || !Number.isFinite(groupId) || groupId < 1) { + throw new Error('请选择分组后再导入') + } + return Math.floor(groupId) +} + +function importForm(file: File, groupId: number): FormData { + const form = new FormData() + form.append('file', file, file.name) + form.append('group_id', String(requireGroupId(groupId))) + return form +} + +/** 启动导入添加(后端 group_id 必填)。 */ +export async function startSkipPriceImport(file: File, groupId: number): Promise { + const { data } = await http.post(SKIP_PRICE_IMPORT_ENDPOINT, importForm(file, groupId)) + return parseImportStart(data) +} + +/** 查询导入添加进度:GET /import/{importId}。 */ +export async function fetchSkipPriceImportProgress(importId: string): Promise { + const { data } = await http.get(`${SKIP_PRICE_IMPORT_ENDPOINT}/${importId}`) + return parseImportProgress(data) +} + +/** 启动导入删除(后端 group_id 必填)。 */ +export async function startSkipPriceDeleteImport(file: File, groupId: number): Promise { + const { data } = await http.post(SKIP_PRICE_DELETE_IMPORT_ENDPOINT, importForm(file, groupId)) + return parseImportStart(data) +} + +/** 查询导入删除进度:GET /delete-import/{importId}。 */ +export async function fetchSkipPriceDeleteImportProgress(importId: string): Promise { + const { data } = await http.get(`${SKIP_PRICE_DELETE_IMPORT_ENDPOINT}/${importId}`) + return parseImportProgress(data) +} diff --git a/admin-frontend-vue/tests/task-76.test.ts b/admin-frontend-vue/tests/task-76.test.ts new file mode 100644 index 00000000..f72e5692 --- /dev/null +++ b/admin-frontend-vue/tests/task-76.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + isAllowedExcelImportFile, + parseImportStart, + parseImportProgress, + importFinished, + importSucceeded, + deleteOutcomeText, +} from '../src/pages/asin/import-progress-model.ts' + +test('test_task_076_skip_price_import_start_normal_primary_path', () => { + // 正常主路径:导入启动结果解析出任务 id。 + assert.equal(parseImportStart({ success: true, data: { importId: 'job_sp_1' } }), 'job_sp_1') +}) + +test('test_task_076_skip_price_import_progress_normal_deleted_and_running', () => { + // 正常主路径:running 进度统计 deletedCount(删除导入) 与 insertedCount(新增导入)。 + const progress = parseImportProgress({ + success: true, + data: { status: 'running', totalRows: 50, processedRows: 20, deletedCount: 3, insertedCount: 5, skippedCount: 2 }, + }) + assert.equal(progress.status, 'running') + assert.equal(progress.deletedCount, 3) + assert.equal(progress.insertedCount, 5) + assert.equal(importFinished(progress), false) +}) + +test('test_task_076_skip_price_import_success_terminal_and_delete_text', () => { + // 正常终态:success 判定成功;删除文案含删除/跳过条数。 + const done = parseImportProgress({ + success: true, + data: { status: 'success', deletedCount: 9, skippedCount: 2 }, + }) + assert.equal(importFinished(done), true) + assert.equal(importSucceeded(done), true) + const text = deleteOutcomeText(done) + assert.match(text, /删除/) + assert.match(text, /9/) +}) + +test('test_task_076_skip_price_import_file_boundary_single_item', () => { + // 边界单元素:.xlsx/.xls 合法。 + assert.equal(isAllowedExcelImportFile('skip.xlsx'), true) + assert.equal(isAllowedExcelImportFile('skip.xls'), true) +}) + +test('test_task_076_skip_price_import_file_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:非 Excel 与空文件名被拒。 + assert.equal(isAllowedExcelImportFile('data.csv'), false) + assert.equal(isAllowedExcelImportFile(''), false) + assert.equal(isAllowedExcelImportFile(undefined as never), false) +}) + +test('test_task_076_skip_price_import_progress_boundary_empty_and_failed', () => { + // 边界/失败:空负载按 pending;failed 终态完成但未成功且错误可读。 + const empty = parseImportProgress({}) + assert.equal(empty.status, 'pending') + assert.equal(importFinished(empty), false) + const failed = parseImportProgress({ success: true, data: { status: 'failed', errorMessage: '导入文件超过大小上限' } }) + assert.equal(importFinished(failed), true) + assert.equal(importSucceeded(failed), false) + assert.match(failed.errorMessage || '', /大小上限/) +}) + +test('test_task_076_skip_price_import_invalid_input_rejected', () => { + // 异常输入:success=false 抛后端 message;缺 importId 抛可读错误。 + assert.throws(() => parseImportStart({ success: false, message: '无权访问' }), /无权访问/) + assert.throws(() => parseImportStart({ success: true, data: {} }), /任务/) + assert.throws(() => parseImportProgress({ success: false, message: '导入任务不存在' }), /导入任务不存在/) +}) + +test('test_task_076_skip_price_import_dependency_failure_returns_actionable_message', () => { + // 依赖失败/轮询走 adapter:import 与 delete-import 均有 POST 启动 + GET 进度,group_id 必填。 + const api = readSource('src/pages/asin/skip-price-import-api.ts') + assert.match(api, /skip-price-asins\/import/) + assert.match(api, /skip-price-asins\/delete-import/) + assert.match(api, /http\.post/) + assert.match(api, /http\.get/) + assert.match(api, /FormData/) + assert.match(api, /group_id/) + assert.match(api, /startSkipPriceImport/) + assert.match(api, /fetchSkipPriceImportProgress|fetchSkipPriceDeleteImportProgress/) +})