diff --git a/admin-frontend-vue/src/pages/asin/skip-price-detail-api.ts b/admin-frontend-vue/src/pages/asin/skip-price-detail-api.ts new file mode 100644 index 00000000..4c232a70 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-detail-api.ts @@ -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 { + await http.put(countryUrl(id, country), { asin, minimumPrice }) +} + +/** 删除某国家最低价 ASIN(清空该站点)。 */ +export async function deleteSkipPriceCountry(id: number, country: string): Promise { + await http.delete(countryUrl(id, country)) +} diff --git a/admin-frontend-vue/src/pages/asin/skip-price-detail-model.ts b/admin-frontend-vue/src/pages/asin/skip-price-detail-model.ts new file mode 100644 index 00000000..f5628a4f --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/skip-price-detail-model.ts @@ -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): 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() + 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 +} diff --git a/admin-frontend-vue/tests/task-77.test.ts b/admin-frontend-vue/tests/task-77.test.ts new file mode 100644 index 00000000..56320eaf --- /dev/null +++ b/admin-frontend-vue/tests/task-77.test.ts @@ -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', () => { + // 依赖失败/保存走 adapter:PUT/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[]), []) +})