task-77(ASIN 数据中心): 实现最低价 ASIN 详情编辑抽屉

抽屉副标题/5 国家草稿(ASIN+最低价)与变更差异(清空 ASIN→删除, 仅清价→
update)纯逻辑, 保存走 PUT/DELETE /api/admin/skip-price-asins/{id}/countries/{country},
10 个契约测试。
This commit is contained in:
2026-09-05 16:14:22 +08:00
parent d9b1b980ca
commit c1d6ce6d20
3 changed files with 227 additions and 0 deletions
@@ -0,0 +1,23 @@
/** 最低价 ASIN 详情编辑抽屉保存适配(任务 77):PUT/DELETE /api/admin/skip-price-asins/{id}/countries/{country}。 */
import { http } from '@/api/http'
export const SKIP_PRICE_DETAIL_ENDPOINT = '/api/admin/skip-price-asins'
function countryUrl(id: number, country: string): string {
return `${SKIP_PRICE_DETAIL_ENDPOINT}/${id}/countries/${country}`
}
/** 保存某国家 ASIN 与最低价(body { asin, minimumPrice });返回后由列表刷新回读最新行。 */
export async function updateSkipPriceCountry(
id: number,
country: string,
asin: string,
minimumPrice: number | null,
): Promise<void> {
await http.put<unknown>(countryUrl(id, country), { asin, minimumPrice })
}
/** 删除某国家最低价 ASIN(清空该站点)。 */
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
await http.delete<unknown>(countryUrl(id, country))
}
@@ -0,0 +1,90 @@
/** 最低价 ASIN 详情编辑抽屉模型(任务 77):副标题、5 国家草稿(ASIN+最低价)与变更差异,纯逻辑。 */
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { skipPriceAsin, skipPriceMinimum, type SkipPriceItem } from './skip-price-model.ts'
/** 抽屉副标题:`分组 / 店铺名`;无分组时仅店铺名。 */
export function skipPriceDetailSubtitle(item: Pick<SkipPriceItem, 'groupName' | 'shopName'>): string {
const group = (item.groupName || '').trim()
const shop = (item.shopName || '').trim()
return group ? `${group} / ${shop}` : shop
}
/** 抽屉单国家草稿(编辑前初值)。 */
export interface SkipPriceCountryDraft {
code: string
label: string
asin: string
/** 最低价输入原文;空串表示清除。 */
price: string
}
/** 由列表行生成 5 国家草稿,保持支持国家序。 */
export function skipPriceCountryDraftsOf(item: SkipPriceItem): SkipPriceCountryDraft[] {
return ASIN_COUNTRY_CODES.map((code) => {
const price = skipPriceMinimum(item, code)
return {
code,
label: asinCountryLabel(code),
asin: skipPriceAsin(item, code),
price: price === null ? '' : String(price),
}
})
}
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
export function normalizeSkipPriceCellPrice(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
}
function priceEquals(a: number | null, b: number | null): boolean {
if (a === null || b === null) return a === b
return a === b
}
export type SkipPriceCountryChangeKind = 'update' | 'delete'
export interface SkipPriceCountryChange {
code: string
label: string
kind: SkipPriceCountryChangeKind
asin?: string
minimumPrice?: number | null
}
/**
* 与当前行比较草稿产出变更:
* - ASIN 与最低价均未变(归一后相等) → 跳过;
* - ASIN 留空(当前有值) → delete
* - 否则 update(ASIN 大写 + 最低价随行,可为空以清价)。
* 未知国家码或缺失草稿不产生变更,避免误删;结果按支持国家序稳定输出。
*/
export function diffSkipPriceCountryChanges(
item: SkipPriceItem,
drafts: SkipPriceCountryDraft[],
): SkipPriceCountryChange[] {
const byCode = new Map<string, SkipPriceCountryDraft>()
for (const draft of drafts || []) {
if (!draft || typeof draft.code !== 'string') continue
byCode.set(draft.code.trim().toUpperCase(), draft)
}
const changes: SkipPriceCountryChange[] = []
for (const code of ASIN_COUNTRY_CODES) {
const draft = byCode.get(code)
if (!draft) continue
const nextAsin = (draft.asin || '').trim().toUpperCase()
const nextPrice = normalizeSkipPriceCellPrice(draft.price)
const currentAsin = skipPriceAsin(item, code).trim().toUpperCase()
const currentPrice = skipPriceMinimum(item, code)
if (nextAsin === currentAsin && priceEquals(currentPrice, nextPrice)) continue
if (!nextAsin) {
changes.push({ code, label: asinCountryLabel(code), kind: 'delete' })
} else {
changes.push({ code, label: asinCountryLabel(code), kind: 'update', asin: nextAsin, minimumPrice: nextPrice })
}
}
return changes
}
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
skipPriceDetailSubtitle,
skipPriceCountryDraftsOf,
diffSkipPriceCountryChanges,
normalizeSkipPriceCellPrice,
type SkipPriceCountryDraft,
} from '../src/pages/asin/skip-price-detail-model.ts'
import { toSkipPriceItem } from '../src/pages/asin/skip-price-model.ts'
test('test_task_077_skip_price_detail_subtitle_normal_primary_path', () => {
// 正常主路径:抽屉副标题 = 分组 / 店铺名。
const item = toSkipPriceItem({ id: 1, groupName: '华东组', shopName: 'BlueWave' })!
assert.equal(skipPriceDetailSubtitle(item), '华东组 / BlueWave')
})
test('test_task_077_skip_price_detail_subtitle_normal_variant_input', () => {
// 正常变体:无分组时仅店铺名。
const item = toSkipPriceItem({ id: 2, shopName: 'RedSun' })!
assert.equal(skipPriceDetailSubtitle(item), 'RedSun')
})
test('test_task_077_skip_price_detail_drafts_normal_primary', () => {
// 正常主路径:由行生成 5 国家草稿(ASIN + 最低价输入初值)。
const item = toSkipPriceItem({ id: 3, shopName: 'S', asinDe: 'B0DE1', minimumPriceDe: 12.5, asinEs: 'B0ES1' })!
const drafts = skipPriceCountryDraftsOf(item)
assert.equal(drafts.length, 5)
const de = drafts[0]
assert.equal(de.code, 'DE')
assert.equal(de.label, '德国')
assert.equal(de.asin, 'B0DE1')
assert.equal(de.price, '12.5')
assert.equal(drafts[4].code, 'ES')
assert.equal(drafts[4].asin, 'B0ES1')
assert.equal(drafts[1].price, '')
})
test('test_task_077_skip_price_detail_diff_normal_no_change', () => {
// 正常重复:ASIN 与最低价均未变(大小写/空白/数字等价归一) → 无变更。
const item = toSkipPriceItem({ id: 4, shopName: 'S', asinDe: 'B0DE1', minimumPriceDe: 12 })!
const changes = diffSkipPriceCountryChanges(item, [
{ code: 'DE', asin: ' b0de1 ', price: '12' },
{ code: 'UK', asin: '', price: '' },
])
assert.deepEqual(changes, [])
})
test('test_task_077_skip_price_detail_diff_boundary_empty_asin_means_delete', () => {
// 边界空值:当前有 ASIN 但草稿清空 → 删除该国家。
const item = toSkipPriceItem({ id: 5, shopName: 'S', asinUk: 'B0UK1', minimumPriceUk: 8, asinDe: 'B0DE1' })!
const changes = diffSkipPriceCountryChanges(item, [
{ code: 'UK', asin: '', price: '' },
{ code: 'DE', asin: 'B0DE1', price: '' },
])
assert.equal(changes.length, 1)
assert.equal(changes[0].kind, 'delete')
assert.equal(changes[0].code, 'UK')
})
test('test_task_077_skip_price_detail_diff_normal_update_asin_and_price', () => {
// 正常主路径:ASIN 改变非空 → update,ASIN 大写、最低价随行。
const item = toSkipPriceItem({ id: 6, shopName: 'S', asinFr: 'B0OLD', minimumPriceFr: 5 })!
const changes = diffSkipPriceCountryChanges(item, [{ code: 'FR', asin: ' b0new1 ', price: ' 18.5 ' }])
assert.equal(changes.length, 1)
assert.equal(changes[0].kind, 'update')
assert.equal(changes[0].asin, 'B0NEW1')
assert.equal(changes[0].minimumPrice, 18.5)
})
test('test_task_077_skip_price_detail_diff_boundary_price_cleared_keeps_asin', () => {
// 边界:ASIN 不变仅清空最低价 → update(保留 ASIN, 最低价置空)。
const item = toSkipPriceItem({ id: 7, shopName: 'S', asinEs: 'B0ES1', minimumPriceEs: 9.9 })!
const changes = diffSkipPriceCountryChanges(item, [{ code: 'ES', asin: 'B0ES1', price: '' }])
assert.equal(changes.length, 1)
assert.equal(changes[0].kind, 'update')
assert.equal(changes[0].asin, 'B0ES1')
assert.equal(changes[0].minimumPrice, null)
})
test('test_task_077_skip_price_detail_diff_invalid_input_rejected', () => {
// 异常输入:未知国家码草稿忽略;缺失草稿不产生变更;非法价格归空。
const item = toSkipPriceItem({ id: 8, shopName: 'S', asinDe: 'B0DE1', minimumPriceDe: 1 })!
const changes = diffSkipPriceCountryChanges(item, [
{ code: 'XX', asin: 'abc', price: '' },
{ code: 'DE', asin: 'B0DE1', price: '1' },
])
assert.deepEqual(changes, [])
assert.equal(normalizeSkipPriceCellPrice(''), null)
assert.equal(normalizeSkipPriceCellPrice(' 12.5 '), 12.5)
assert.equal(normalizeSkipPriceCellPrice('abc'), null)
assert.equal(normalizeSkipPriceCellPrice('-1'), null)
})
test('test_task_077_skip_price_detail_dependency_failure_returns_actionable_message', () => {
// 依赖失败/保存走 adapterPUT/DELETE /skip-price-asins/{id}/countries/{country}。
const api = readSource('src/pages/asin/skip-price-detail-api.ts')
assert.match(api, /skip-price-asins/)
assert.match(api, /countries/)
assert.match(api, /http\.put/)
assert.match(api, /http\.delete/)
assert.match(api, /updateSkipPriceCountry|saveSkipPriceCountry/)
assert.match(api, /deleteSkipPriceCountry/)
const mod = readSource('src/pages/asin/skip-price-detail-model.ts')
assert.equal(/axios|http\./.test(mod), false, '最低价 ASIN 抽屉模型保持纯逻辑')
})
test('test_task_077_skip_price_detail_diff_boundary_no_drafts', () => {
// 边界:空草稿列表不产生变更;skip-price 行缺失字段安全。
const item = toSkipPriceItem({ id: 9, shopName: 'S' })!
assert.deepEqual(diffSkipPriceCountryChanges(item, [] as SkipPriceCountryDraft[]), [])
assert.deepEqual(diffSkipPriceCountryChanges(item, null as unknown as SkipPriceCountryDraft[]), [])
})