task-76(ASIN 数据中心): 实现最低价 ASIN 导入与删除导入
POST /api/admin/skip-price-asins/import、/delete-import(file+group_id 必填) 启动 + GET 进度轮询, 复用新增通用导入进度模型(含 deletedCount), 8 个契约测试。
This commit is contained in:
@@ -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<unknown>(payload)
|
||||||
|
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : 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<unknown>(payload)
|
||||||
|
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : 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<ImportProgress, 'status'>): boolean {
|
||||||
|
return progress.status === 'success' || progress.status === 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否成功完成。 */
|
||||||
|
export function importSucceeded(progress: Pick<ImportProgress, 'status'>): 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 '等待删除任务开始…'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
const { data } = await http.post<unknown>(SKIP_PRICE_IMPORT_ENDPOINT, importForm(file, groupId))
|
||||||
|
return parseImportStart(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询导入添加进度:GET /import/{importId}。 */
|
||||||
|
export async function fetchSkipPriceImportProgress(importId: string): Promise<ImportProgress> {
|
||||||
|
const { data } = await http.get<unknown>(`${SKIP_PRICE_IMPORT_ENDPOINT}/${importId}`)
|
||||||
|
return parseImportProgress(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动导入删除(后端 group_id 必填)。 */
|
||||||
|
export async function startSkipPriceDeleteImport(file: File, groupId: number): Promise<string> {
|
||||||
|
const { data } = await http.post<unknown>(SKIP_PRICE_DELETE_IMPORT_ENDPOINT, importForm(file, groupId))
|
||||||
|
return parseImportStart(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询导入删除进度:GET /delete-import/{importId}。 */
|
||||||
|
export async function fetchSkipPriceDeleteImportProgress(importId: string): Promise<ImportProgress> {
|
||||||
|
const { data } = await http.get<unknown>(`${SKIP_PRICE_DELETE_IMPORT_ENDPOINT}/${importId}`)
|
||||||
|
return parseImportProgress(data)
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user