Files
crawler-plugin/admin-frontend-vue/src/pages/asin/skip-price-model.ts
T
huangzd1997 43c7c03aee task-75(ASIN 数据中心): 实现最低价 ASIN 列表和筛选
GET /api/admin/skip-price-asins 列表解析(SkipPriceAsinPageVo, 每店铺
5 国家 ASIN+最低价) + 分组/店铺名/ASIN/国家/最低价区间筛选 snake 序列化,
新增共享国家字典, 9 个契约测试。
2026-09-05 16:11:43 +08:00

132 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 最低价 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;否则 nullBigDecimal 可能以字符串下发)。 */
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
}