feat(syb): confirm one-click replacement from latest collection #258
This commit is contained in:
@@ -35,9 +35,9 @@
|
||||
<div class="quick-link-actions">
|
||||
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
|
||||
<el-button-group v-if="quickCanLink(row)">
|
||||
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">一键关联</el-button>
|
||||
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">{{ quickActionLabel(row) }}</el-button>
|
||||
<el-dropdown trigger="click" @command="quickOpenPicker()">
|
||||
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
|
||||
<el-button size="small" type="primary" plain :aria-label="`${quickActionLabel(row)}使用的手机`" :icon="ArrowDown" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
|
||||
@@ -57,7 +57,7 @@
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
<p>{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}</p>
|
||||
<p>{{ quickPicker.row ? '选择后继续处理刚才的商品;更换为其他商品前会再次确认。' : '只修改各行共用的手机,不启动关联或替换,也不影响正在进行的操作。' }}</p>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="采集手机">
|
||||
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
|
||||
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
deactivated() { this.quickInvalidate() },
|
||||
methods: {
|
||||
quickInvalidate() {
|
||||
if (this.quickPhase === '等待替换确认') ElMessageBox.close()
|
||||
this.quickGeneration++
|
||||
this.quickPickerGeneration++
|
||||
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
|
||||
@@ -29,7 +30,16 @@ export default {
|
||||
},
|
||||
quickCanLink(row) {
|
||||
const ready = this.purchaseReady(row)
|
||||
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
|
||||
return this.canPurchase && !!row.shopeeProductId && ((ready.processNextAction === 'open_pdd_link' && !ready.pddProductId) || this.quickCanReplace(row))
|
||||
},
|
||||
quickCanReplace(row) {
|
||||
const ready = this.purchaseReady(row)
|
||||
return !!ready.pddProductId && ready.processNextAction === 'open_pdd' && (
|
||||
['pdd_pending', 'pdd_collection_failed'].includes(ready.processStage) ||
|
||||
(ready.processStage === 'manual_action' && ['PDD_NOT_READY', 'PDD_PRICE_MISSING'].includes(ready.reasonCode)))
|
||||
},
|
||||
quickActionLabel(row) {
|
||||
return this.quickCanReplace(row) ? '一键替换' : '一键关联'
|
||||
},
|
||||
quickDeviceLabel() {
|
||||
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
|
||||
@@ -61,34 +71,60 @@ export default {
|
||||
async quickStart(row) {
|
||||
if (!this.quickCanLink(row) || this.quickBusyId) return
|
||||
if (!this.quickDeviceId) return this.quickOpenPicker(row)
|
||||
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId }
|
||||
const replacing = this.quickCanReplace(row)
|
||||
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId, oldPddId: this.purchaseReady(row).pddProductId }
|
||||
const generation = ++this.quickGeneration
|
||||
const current = () => generation === this.quickGeneration
|
||||
this.quickBusyId = row.id
|
||||
this.quickMessages[row.id] = ''
|
||||
this.quickPhase = '正在关联'
|
||||
let wrote = false
|
||||
let targetId = null
|
||||
try {
|
||||
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
|
||||
if (!current()) return
|
||||
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
|
||||
if (!replacing && detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
|
||||
if (replacing && detail.product.pddProductId !== frozen.oldPddId) throw new Error('关联已变化,请刷新列表')
|
||||
const latestCollection = { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
|
||||
if (replacing) {
|
||||
this.quickPhase = '正在读取最近采集'
|
||||
const preview = (await linkShopeeProductPdd(frozen.productId, {
|
||||
requestId: createRequestId(), latestCollection: { ...latestCollection, replacement: { preview: true, expectedPddProductId: frozen.oldPddId }}
|
||||
}, readOptions)).data
|
||||
if (!current()) return
|
||||
const source = preview.collectionSource
|
||||
targetId = source.pddProductId
|
||||
latestCollection.replacement = { preview: false, expectedPddProductId: frozen.oldPddId, taskId: source.taskId, targetPddProductId: targetId, targetVersion: source.targetVersion }
|
||||
if (targetId !== frozen.oldPddId) {
|
||||
this.quickPhase = '等待替换确认'
|
||||
try {
|
||||
await ElMessageBox.confirm(`将「${source.previousTitle || '原商品'}(${source.previousGoodsId || frozen.oldPddId})」替换为「${source.title || '最近采集商品'}(${source.goodsId || targetId})」。来源:${source.deviceName || '手机'} #${frozen.deviceId}。共用此蝦皮商品的 SYB 明细也会使用新关联;已有采购任务不变。`, '确认替换 PDD 商品', { confirmButtonText: '确认替换并匹配', cancelButtonText: '取消', closeOnClickModal: false })
|
||||
} catch {
|
||||
if (current()) this.quickMessages[row.id] = '已取消,未修改关联'
|
||||
return
|
||||
}
|
||||
if (!current()) return
|
||||
}
|
||||
this.quickPhase = targetId === frozen.oldPddId ? '正在核对现有关联' : '正在替换'
|
||||
}
|
||||
const linked = (await linkShopeeProductPdd(frozen.productId, {
|
||||
requestId: createRequestId(),
|
||||
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
|
||||
latestCollection
|
||||
}, readOptions)).data
|
||||
wrote = true
|
||||
if (!current()) return
|
||||
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
|
||||
if (!current()) return
|
||||
const outcome = replacing && targetId !== frozen.oldPddId ? '已替换' : '已关联'
|
||||
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
|
||||
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
|
||||
this.quickMessages[row.id] = ready?.eligible ? `${outcome},当前明细可创建采购` : `${outcome};请按处理阶段提示继续人工处理`
|
||||
} else {
|
||||
this.quickPhase = '正在匹配并保存'
|
||||
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
|
||||
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
|
||||
}, detail.autoMatchTimeoutSeconds)).data
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = result.unmatchedCount ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
|
||||
this.quickMessages[row.id] = result.unmatchedCount ? `${outcome}并保存有效匹配;剩余规格请去匹配处理` : `${outcome}并保存匹配;以最新处理阶段为准`
|
||||
}
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
@@ -102,9 +138,13 @@ export default {
|
||||
try {
|
||||
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = saved.pddProductId
|
||||
? `当前已有关联,匹配结果请去匹配查看。${reason}`
|
||||
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
|
||||
this.quickMessages[row.id] = replacing
|
||||
? (targetId && saved.pddProductId === targetId
|
||||
? `当前关联为本次目标商品,匹配结果请去匹配查看。${reason}`
|
||||
: `当前关联${saved.pddProductId === frozen.oldPddId ? '仍为原商品' : '已变化'};本次结果未确认,请刷新核对,不自动重试。${reason}`)
|
||||
: saved.pddProductId
|
||||
? `当前已有关联,匹配结果请去匹配查看。${reason}`
|
||||
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
|
||||
} catch {
|
||||
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
async function setup(page: any) {
|
||||
async function setup(page: any, replacing = false) {
|
||||
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
|
||||
const replaced = new Set<number>()
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async (route: any) => {
|
||||
const url = new URL(route.request().url()), path = url.pathname
|
||||
@@ -11,13 +12,18 @@ async function setup(page: any) {
|
||||
if (path.endsWith('/menurole')) return ok([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }])
|
||||
if (path.endsWith('/syb-products')) return ok({ items: [1, 2].map(id => ({ id, orderCode: `TEST-${id}`, shopeeItemId: `100${id}`, shopeeProductId: id + 10, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' })), total: 2 })
|
||||
if (path.endsWith('/devices')) return ok({ items: [{ id: 10, name: '手机A', status: 'offline' }, { id: 20, name: '手机B', status: 'online' }], total: 2 })
|
||||
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => ({ sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' })) })
|
||||
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => replacing && !replaced.has(id)
|
||||
? { sybProductId: id, pddProductId: 44, processStage: 'pdd_collection_failed', processNextAction: 'open_pdd' }
|
||||
: { sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' }) })
|
||||
const match = path.match(/shopee-products\/(\d+)(.*)/)
|
||||
if (match) {
|
||||
const productId = Number(match[1]), id = productId - 10
|
||||
const product = { id: productId, pddProductId: state.linked.has(id) ? 55 : null, specContextVersion: state.linked.has(id) ? 'after' : 'before', specs: [] }
|
||||
if (replacing && !replaced.has(id)) product.pddProductId = 44
|
||||
if (match[2] === '/link-pdd') {
|
||||
const body = route.request().postDataJSON(); state.links.push(body); state.linked.add(id)
|
||||
const body = route.request().postDataJSON()
|
||||
if (body.latestCollection.replacement?.preview) return ok({ product, collectionSource: { taskId: 7, deviceId: 10, pddProductId: 55, targetVersion: 'frozen', title: '新测试商品', previousTitle: '原测试商品', deviceName: '手机A' } })
|
||||
state.links.push(body); state.linked.add(id); replaced.add(id)
|
||||
return ok({ product: { ...product, pddProductId: 55, specContextVersion: 'after' }, collectionSource: { taskId: 7, deviceId: body.latestCollection.deviceId, pddProductId: 55 } })
|
||||
}
|
||||
if (match[2] === '/specs/mapping/auto-match') {
|
||||
@@ -30,12 +36,30 @@ async function setup(page: any) {
|
||||
return ok([])
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
|
||||
await expect(page.getByRole('button', { name: replacing ? '一键替换' : '一键关联', exact: true })).toHaveCount(2)
|
||||
return state
|
||||
}
|
||||
|
||||
test('replacement reuses row position; cancel writes nothing; confirm freezes target and matches', async ({ page }) => {
|
||||
const state = await setup(page, true)
|
||||
await expect(page.getByRole('button', { name: '查看 PDD 商品', exact: true })).toHaveCount(2)
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
|
||||
await choose(page, '手机A', '选择并继续')
|
||||
const confirm = page.getByRole('dialog', { name: '确认替换 PDD 商品' })
|
||||
await expect(confirm).toContainText('共用此蝦皮商品')
|
||||
await confirm.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(state.links).toHaveLength(0); expect(state.matches).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
|
||||
await confirm.getByRole('button', { name: '确认替换并匹配', exact: true }).click()
|
||||
await expect.poll(() => state.matches.length).toBe(1)
|
||||
expect(state.links).toHaveLength(1)
|
||||
expect(state.links[0].latestCollection.replacement).toMatchObject({ expectedPddProductId: 44, taskId: 7, targetPddProductId: 55, targetVersion: 'frozen' })
|
||||
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
async function choose(page: any, name: string, button: string) {
|
||||
const dialog = page.getByRole('dialog')
|
||||
const dialog = page.getByRole('dialog', { name: /^(选择|切换)采集手机$/ })
|
||||
await dialog.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: new RegExp(name) }).click()
|
||||
await dialog.getByRole('button', { name: button, exact: true }).click()
|
||||
|
||||
@@ -10,6 +10,7 @@ function instance(overrides = {}) {
|
||||
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
|
||||
const dependencies = {
|
||||
ElMessage: { warning() {} },
|
||||
ElMessageBox: { confirm: async () => {}, close() {} },
|
||||
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
|
||||
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
|
||||
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
|
||||
@@ -87,3 +88,48 @@ test('preference isolates user and API environment, handles storage failure', ()
|
||||
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
|
||||
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
|
||||
})
|
||||
|
||||
function replacementInstance(overrides = {}) {
|
||||
const writes = [], previews = [], confirms = []
|
||||
const context = instance({
|
||||
getShopeeProduct: async () => ({ data: { product: { pddProductId: 44, specContextVersion: 'old' }, autoMatchTimeoutSeconds: 130 } }),
|
||||
ElMessageBox: { confirm: async (...args) => { confirms.push(args) }, close() {} },
|
||||
linkShopeeProductPdd: async (id, body) => {
|
||||
const r = body.latestCollection.replacement
|
||||
;(r.preview ? previews : writes).push(body)
|
||||
return { data: { product: { specContextVersion: 'new', pddProductId: r.preview ? 44 : 55 }, collectionSource: { taskId: 9, pddProductId: 55, targetVersion: 'target', title: '新商品', previousTitle: '原商品', deviceName: 'A' } } }
|
||||
}, ...overrides
|
||||
})
|
||||
context.vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: 'pdd_collection_failed' })
|
||||
context.vm.quickDeviceId = 10
|
||||
return { ...context, writes, previews, confirms }
|
||||
}
|
||||
test('replacement confirms once and submits the frozen source', async () => {
|
||||
const { vm, writes, previews, confirms, calls } = replacementInstance()
|
||||
await vm.quickStart(row)
|
||||
assert.equal(previews.length, 1); assert.equal(confirms.length, 1); assert.equal(writes.length, 1)
|
||||
assert.deepEqual(writes[0].latestCollection.replacement, { preview: false, expectedPddProductId: 44, taskId: 9, targetPddProductId: 55, targetVersion: 'target' })
|
||||
assert.equal(calls.match.length, 1)
|
||||
})
|
||||
test('cancel replacement never writes or matches', async () => {
|
||||
const { vm, writes, calls } = replacementInstance({ ElMessageBox: { confirm: async () => { throw 'cancel' } } })
|
||||
await vm.quickStart(row)
|
||||
assert.equal(writes.length, 0); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /已取消/)
|
||||
})
|
||||
test('same product continues matching without replacement confirmation', async () => {
|
||||
const { vm, confirms, calls } = replacementInstance({ linkShopeeProductPdd: async () => ({ data: { product: { specContextVersion: 'old' }, collectionSource: { taskId: 9, pddProductId: 44, targetVersion: 'same' } } }) })
|
||||
await vm.quickStart(row)
|
||||
assert.equal(confirms.length, 0); assert.equal(calls.match.length, 1)
|
||||
})
|
||||
test('failed replacement readback does not call old association a success', async () => {
|
||||
const { vm, calls } = replacementInstance({ linkShopeeProductPdd: async () => { throw new Error('timeout') } })
|
||||
await vm.quickStart(row)
|
||||
assert.match(vm.quickMessages[1], /仍为原商品/); assert.equal(calls.match.length, 0)
|
||||
})
|
||||
test('replacement is not shown for collecting, successful, unknown or other manual stages', () => {
|
||||
const { vm } = replacementInstance()
|
||||
for (const stage of ['pdd_collecting', 'purchase_succeeded', 'order_review', 'task_created', 'color_mapping', 'manual_action']) {
|
||||
vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: stage })
|
||||
assert.equal(vm.quickCanLink(row), false, stage)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user