diff --git a/admin-frontend-vue/src/pages/asin/asin-country.ts b/admin-frontend-vue/src/pages/asin/asin-country.ts new file mode 100644 index 00000000..964fcb3f --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/asin-country.ts @@ -0,0 +1,23 @@ +/** ASIN 数据中心共享国家字典(任务 75):支持国家码与中文标签,纯逻辑。 */ + +export const ASIN_COUNTRY_CODES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const +export type AsinCountryCode = (typeof ASIN_COUNTRY_CODES)[number] + +export const ASIN_COUNTRY_LABELS: Record = { + DE: '德国', + UK: '英国', + FR: '法国', + IT: '意大利', + ES: '西班牙', +} + +/** 国家码 → 中文标签;不属白名单原样返回。 */ +export function asinCountryLabel(country: string): string { + return ASIN_COUNTRY_LABELS[country as AsinCountryCode] || country +} + +/** 国家码归一:大小写不敏感;不属白名单时返回空串。 */ +export function normalizeAsinCountry(value: string): string { + const upper = (value || '').trim().toUpperCase() + return ASIN_COUNTRY_CODES.includes(upper as AsinCountryCode) ? upper : '' +} diff --git a/admin-frontend-vue/src/pages/asin/skip-price-api.ts b/admin-frontend-vue/src/pages/asin/skip-price-api.ts new file mode 100644 index 00000000..f364444c --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-api.ts @@ -0,0 +1,16 @@ +/** 最低价 ASIN 列表查询适配(任务 75):GET /api/admin/skip-price-asins + snake 分页/筛选序列化。 */ +import { http } from '@/api/http' +import { parseSkipPricePage, type SkipPricePageResult } from './skip-price-model' +import { + normalizeSkipPriceParams, + toSkipPriceQuery, + type SkipPriceListParams, +} from './skip-price-filter' + +export const SKIP_PRICE_ENDPOINT = '/api/admin/skip-price-asins' + +export async function fetchSkipPriceList(params: Partial = {}): Promise { + const normalized = normalizeSkipPriceParams(params) + const { data } = await http.get(SKIP_PRICE_ENDPOINT, { params: toSkipPriceQuery(normalized) }) + return parseSkipPricePage(data) +} diff --git a/admin-frontend-vue/src/pages/asin/skip-price-filter.ts b/admin-frontend-vue/src/pages/asin/skip-price-filter.ts new file mode 100644 index 00000000..42e68d42 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-filter.ts @@ -0,0 +1,121 @@ +/** 最低价 ASIN 页面筛选/分页状态(任务 75):分组/店铺名/ASIN/国家 + 最低价区间;Java 参数名 snake。 */ +import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts' +import { normalizeAsinCountry } from './asin-country.ts' + +/** 后端 SkipPriceAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */ +export const SKIP_PRICE_MAX_PAGE_SIZE = 100 + +export interface SkipPriceFilterState { + groupId: number | null + shopName: string + asin: string + /** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */ + country: string + /** 最低价区间输入原文;空串不限。 */ + minimumPriceFrom: string + minimumPriceTo: string +} + +export interface SkipPriceListParams { + page: number + pageSize: number + groupId?: number | null + shopName?: string + asin?: string + country?: string + minimumPriceFrom?: number | null + minimumPriceTo?: number | null +} + +export interface SkipPriceQuery { + page: number + page_size: number + group_id?: number + shop_name?: string + asin?: string + country?: string + minimum_price_from?: number + minimum_price_to?: number +} + +export function createSkipPriceFilterState(): SkipPriceFilterState { + return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' } +} + +/** 非负可解析价格 → number;空/非法/负数返回 null。 */ +export function normalizePriceInput(value: string): number | null { + const trimmed = (value || '').trim() + if (!trimmed) return null + if (!/^\d+(\.\d+)?$/.test(trimmed)) return null + const num = Number(trimmed) + return Number.isFinite(num) && num >= 0 ? num : null +} + +/** 是否有任一激活筛选条件。 */ +export function skipPriceFilterActive(state: SkipPriceFilterState): boolean { + if (typeof state.groupId === 'number' && state.groupId >= 1) return true + if ((state.shopName || '').trim()) return true + if ((state.asin || '').trim()) return true + if (normalizeAsinCountry(state.country) !== '') return true + return normalizePriceInput(state.minimumPriceFrom) !== null || normalizePriceInput(state.minimumPriceTo) !== null +} + +/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */ +export function normalizeSkipPriceParams(raw: Partial): SkipPriceListParams { + const page = typeof raw.page === 'number' && Number.isFinite(raw.page) ? Math.max(Math.floor(raw.page), 1) : 1 + const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize) ? Math.floor(raw.pageSize) : NaN + const pageSize = Number.isNaN(rawSize) || rawSize < 1 ? ASIN_PAGE_DEFAULT_SIZE : Math.min(rawSize, SKIP_PRICE_MAX_PAGE_SIZE) + const groupId = + typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1 ? Math.floor(raw.groupId) : null + const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : '' + const asin = typeof raw.asin === 'string' ? raw.asin.trim() : '' + const country = normalizeAsinCountry(typeof raw.country === 'string' ? raw.country : '') + const minimumPriceFrom = typeof raw.minimumPriceFrom === 'number' && Number.isFinite(raw.minimumPriceFrom) + ? Math.max(raw.minimumPriceFrom, 0) + : null + const minimumPriceTo = typeof raw.minimumPriceTo === 'number' && Number.isFinite(raw.minimumPriceTo) + ? Math.max(raw.minimumPriceTo, 0) + : null + const params: SkipPriceListParams = { page, pageSize } + if (groupId !== null) params.groupId = groupId + if (shopName) params.shopName = shopName + if (asin) params.asin = asin + if (country) params.country = country + if (minimumPriceFrom !== null) params.minimumPriceFrom = minimumPriceFrom + if (minimumPriceTo !== null) params.minimumPriceTo = minimumPriceTo + return params +} + +/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */ +export function toSkipPriceParams( + state: SkipPriceFilterState, + page: number, + pageSize: number, +): SkipPriceListParams { + return normalizeSkipPriceParams({ + page, + pageSize, + groupId: state.groupId, + shopName: state.shopName, + asin: state.asin, + country: state.country, + minimumPriceFrom: normalizePriceInput(state.minimumPriceFrom), + minimumPriceTo: normalizePriceInput(state.minimumPriceTo), + }) +} + +/** 前端查询参数 → Java 查询参数;与 SkipPriceAsinController @RequestParam(snake) 对齐,仅下发非空字段。 */ +export function toSkipPriceQuery(params: SkipPriceListParams): SkipPriceQuery { + const query: SkipPriceQuery = { page: params.page, page_size: params.pageSize } + if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId + if (params.shopName) query.shop_name = params.shopName + if (params.asin) query.asin = params.asin + if (params.country) query.country = params.country + if (typeof params.minimumPriceFrom === 'number' && Number.isFinite(params.minimumPriceFrom)) { + query.minimum_price_from = params.minimumPriceFrom + } + if (typeof params.minimumPriceTo === 'number' && Number.isFinite(params.minimumPriceTo)) { + query.minimum_price_to = params.minimumPriceTo + } + return query +} diff --git a/admin-frontend-vue/src/pages/asin/skip-price-model.ts b/admin-frontend-vue/src/pages/asin/skip-price-model.ts new file mode 100644 index 00000000..a8749f77 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-model.ts @@ -0,0 +1,131 @@ +/** 最低价 ASIN 列表模型(任务 75):解析 SkipPriceAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN+最低价),纯逻辑。 */ +import { unwrap } from '../../api/envelope.ts' +import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts' +import { ASIN_COUNTRY_CODES, type AsinCountryCode } from './asin-country.ts' + +export interface SkipPriceItem { + id: number + groupId: number | null + groupName: string + shopName: string + asinDe: string + minimumPriceDe: number | null + asinUk: string + minimumPriceUk: number | null + asinFr: string + minimumPriceFr: number | null + asinIt: string + minimumPriceIt: number | null + asinEs: string + minimumPriceEs: number | null + createdAt?: string + updatedAt?: string +} + +export interface SkipPricePageResult { + items: SkipPriceItem[] + total: number + page: number + pageSize: number +} + +export function emptySkipPricePage(): SkipPricePageResult { + return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE } +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +/** 数字或可解析数字串 → number;否则 null(BigDecimal 可能以字符串下发)。 */ +function numberOrNull(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed && Number.isFinite(Number(trimmed))) return Number(trimmed) + } + return null +} + +function idOrNull(value: unknown): number | null { + const num = numberOrNull(value) + return num === null ? null : Math.floor(num) +} + +function lowerCode(code: AsinCountryCode): string { + return code.toLowerCase() +} + +/** 'DE' → 'De',拼出 VO 驼峰字段后缀(asinDe/minimumPriceDe)。 */ +function countrySuffix(code: string): string { + return code.length > 1 ? code[0] + code.slice(1).toLowerCase() : code.toUpperCase() +} + +/** 解析单条最低价 ASIN 店铺行;缺 id 视为无效。 */ +export function toSkipPriceItem(raw: unknown): SkipPriceItem | null { + if (!raw || typeof raw !== 'object') return null + const record = raw as Record + const id = idOrNull(record.id) + if (id === null) return null + const item: SkipPriceItem = { + id, + groupId: idOrNull(record.groupId ?? record.group_id), + groupName: text(record.groupName ?? record.group_name), + shopName: text(record.shopName ?? record.shop_name), + asinDe: '', + minimumPriceDe: null, + asinUk: '', + minimumPriceUk: null, + asinFr: '', + minimumPriceFr: null, + asinIt: '', + minimumPriceIt: null, + asinEs: '', + minimumPriceEs: null, + } + const writable = item as unknown as Record + for (const code of ASIN_COUNTRY_CODES) { + const suffix = countrySuffix(code) + const camelAsin = `asin${suffix}` + const snakeAsin = `asin_${lowerCode(code)}` + writable[camelAsin] = text(record[camelAsin] ?? record[snakeAsin]) + const camelPrice = `minimumPrice${suffix}` + const snakePrice = `minimum_price_${lowerCode(code)}` + writable[camelPrice] = numberOrNull(record[camelPrice] ?? record[snakePrice]) + } + const createdAt = text(record.createdAt ?? record.created_at) + if (createdAt) item.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) item.updatedAt = updatedAt + return item +} + +/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */ +export function parseSkipPricePage(payload: unknown): SkipPricePageResult { + const out = emptySkipPricePage() + const core = unwrap(payload) + if (!core || typeof core !== 'object') return out + const record = core as Record + if (Array.isArray(record.items)) { + out.items = record.items + .map((raw) => toSkipPriceItem(raw)) + .filter((item): item is SkipPriceItem => item !== null) + } + if (typeof record.total === 'number') out.total = Math.floor(record.total) + if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page) + const rawSize = record.pageSize ?? record.page_size + if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize) + return out +} + +/** 取某国家列 ASIN;国家码不属白名单返回空串。 */ +export function skipPriceAsin(item: SkipPriceItem, country: string): string { + if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return '' + return item[`asin${countrySuffix(country)}` as keyof SkipPriceItem] as string +} + +/** 取某国家最低价;国家码不属白名单返回 null。 */ +export function skipPriceMinimum(item: SkipPriceItem, country: string): number | null { + if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return null + return item[`minimumPrice${countrySuffix(country)}` as keyof SkipPriceItem] as number | null +} diff --git a/admin-frontend-vue/tests/task-75.test.ts b/admin-frontend-vue/tests/task-75.test.ts new file mode 100644 index 00000000..860c5628 --- /dev/null +++ b/admin-frontend-vue/tests/task-75.test.ts @@ -0,0 +1,165 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + parseSkipPricePage, + toSkipPriceItem, + skipPriceAsin, + skipPriceMinimum, + type SkipPriceItem, +} from '../src/pages/asin/skip-price-model.ts' +import { ASIN_COUNTRY_CODES, asinCountryLabel } from '../src/pages/asin/asin-country.ts' +import { + skipPriceFilterActive, + toSkipPriceParams, + toSkipPriceQuery, + type SkipPriceFilterState, +} from '../src/pages/asin/skip-price-filter.ts' + +test('test_task_075_skip_price_list_normal_primary_path', () => { + // 正常主路径:SkipPriceAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN+最低价)解析。 + const page = parseSkipPricePage({ + success: true, + data: { + items: [ + { + id: 11, + groupId: 3, + groupName: '华东组', + shopName: 'BlueWave', + asinDe: 'B0DE001', + minimumPriceDe: 12.5, + asinUk: 'B0UK001', + minimumPriceUk: 8, + asinFr: '', + minimumPriceFr: null, + asinIt: '', + minimumPriceIt: null, + asinEs: '', + minimumPriceEs: null, + }, + ], + total: 1, + page: 1, + pageSize: 15, + }, + }) + assert.equal(page.items.length, 1) + const item = page.items[0] + assert.equal(item.shopName, 'BlueWave') + assert.equal(item.asinDe, 'B0DE001') + assert.equal(item.minimumPriceDe, 12.5) + assert.equal(item.minimumPriceUk, 8) + assert.equal(item.minimumPriceFr, null) + assert.equal(page.total, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_075_skip_price_list_normal_variant_input', () => { + // 正常变体:蛇形字段兜底;缺国家价格留空;价格支持数字字符串。 + const item = toSkipPriceItem({ + id: 12, + shopName: 'RedSun', + asin_de: 'B0DE2', + minimum_price_de: '9.99', + })! + assert.equal(item.asinDe, 'B0DE2') + assert.equal(item.minimumPriceDe, 9.99) + assert.equal(item.groupId, null) + assert.equal(item.groupName, '') + assert.equal(item.minimumPriceUk, null) +}) + +test('test_task_075_skip_price_list_normal_repeated_operation_is_idempotent', () => { + // 正常重复:解析不改输入、结果稳定。 + const payload = { + data: { items: [{ id: 13, shopName: 'A', asinDe: 'X', minimumPriceDe: 1 }], total: 1, page: 1, pageSize: 15 }, + } + assert.deepEqual(parseSkipPricePage(payload), parseSkipPricePage(payload)) +}) + +test('test_task_075_skip_price_list_boundary_empty_input', () => { + // 边界空值:空负载回默认分页结果。 + const page = parseSkipPricePage({}) + assert.deepEqual(page.items, []) + assert.equal(page.total, 0) + assert.equal(page.page, 1) + assert.equal(page.pageSize, 15) +}) + +test('test_task_075_skip_price_list_boundary_single_item', () => { + // 边界单元素:缺 id 行被过滤;国家访问器/标签齐全。 + const page = parseSkipPricePage({ + data: { items: [{ id: 7, shopName: 'Z' }, { shopName: 'no-id' }], total: 2, page: 1, pageSize: 15 }, + }) + assert.equal(page.items.length, 1) + const item: SkipPriceItem = page.items[0] + assert.equal(skipPriceAsin(item, 'DE'), '') + assert.equal(skipPriceMinimum(item, 'DE'), null) + assert.equal(ASIN_COUNTRY_CODES.length, 5) + assert.equal(asinCountryLabel('DE'), '德国') + assert.equal(asinCountryLabel('XX'), 'XX') +}) + +test('test_task_075_skip_price_list_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:页码下限 1、页大小上限 100 对齐后端;空格/非法分组/国家不下发。 + const state: SkipPriceFilterState = { groupId: 0, shopName: ' ', asin: '', country: 'XX', minimumPriceFrom: '', minimumPriceTo: '' } + const query = toSkipPriceQuery(toSkipPriceParams(state, 0, 999)) + assert.equal(query.page, 1) + assert.equal(query.page_size, 100) + assert.equal('group_id' in query, false) + assert.equal('shop_name' in query, false) + assert.equal('country' in query, false) + assert.equal(skipPriceFilterActive(state), false) +}) + +test('test_task_075_skip_price_filter_normal_primary_and_price', () => { + // 正常主路径:分组/店铺/ASIN/国家 + 价格上下限以 snake 参数下发;空值不下发。 + const state: SkipPriceFilterState = { + groupId: 5, + shopName: ' 蓝店 ', + asin: 'B0X', + country: 'de', + minimumPriceFrom: ' 1.5 ', + minimumPriceTo: '99', + } + const query = toSkipPriceQuery(toSkipPriceParams(state, 3, 25)) + assert.deepEqual(query, { + page: 3, + page_size: 25, + group_id: 5, + shop_name: '蓝店', + asin: 'B0X', + country: 'DE', + minimum_price_from: 1.5, + minimum_price_to: 99, + }) +}) + +test('test_task_075_skip_price_filter_invalid_input_rejected', () => { + // 异常输入:非法价格/负数/非数值价格丢弃;success=false 抛 message;垃圾行不入列表。 + const state: SkipPriceFilterState = { + groupId: null, + shopName: '', + asin: '', + country: '', + minimumPriceFrom: '-1', + minimumPriceTo: 'abc', + } + const params = toSkipPriceParams(state, 1, 15) + assert.equal('minimum_price_from' in (params as Record), false) + assert.equal('minimum_price_to' in (params as Record), false) + assert.throws(() => parseSkipPricePage({ success: false, message: '无权访问跳过跟价 ASIN' }), /无权/) + assert.equal(toSkipPriceItem('garbage'), null) + assert.equal(toSkipPriceItem({ shopName: 'no id' }), null) +}) + +test('test_task_075_skip_price_list_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET /api/admin/skip-price-asins + http.get + 解析。 + const api = readSource('src/pages/asin/skip-price-api.ts') + assert.match(api, /skip-price-asins/) + assert.match(api, /http\.get/) + assert.match(api, /parseSkipPricePage/) + const model = readSource('src/pages/asin/skip-price-model.ts') + assert.equal(/axios|http\./.test(model), false, '最低价 ASIN 列表解析保持纯逻辑') +})