task-75(ASIN 数据中心): 实现最低价 ASIN 列表和筛选
GET /api/admin/skip-price-asins 列表解析(SkipPriceAsinPageVo, 每店铺 5 国家 ASIN+最低价) + 分组/店铺名/ASIN/国家/最低价区间筛选 snake 序列化, 新增共享国家字典, 9 个契约测试。
This commit is contained in:
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<unknown>(payload)
|
||||
if (!core || typeof core !== 'object') return out
|
||||
const record = core as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user