fix(#82): separate PDD linking from mapping
This commit is contained in:
@@ -315,13 +315,13 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联拼多多商品,请先去匹配", "open_mapping"
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
|
||||
return item
|
||||
}
|
||||
item.PDDProductID = shopee.PDDProductID
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的拼多多商品不存在,请先重新匹配", "open_mapping"
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink
|
||||
return item
|
||||
}
|
||||
item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title
|
||||
|
||||
@@ -43,6 +43,23 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("pdd_product_id", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.Eligible || item.ReasonCode != "PDD_NOT_LINKED" || item.NextAction != processActionOpenPDDLink || item.ProcessNextAction != processActionOpenPDDLink {
|
||||
t.Fatalf("PDD link action missing: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateKeepsPartialSuccessAndReplaysCreatedItems(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
ProcessStageTaskCreated = "task_created"
|
||||
ProcessStagePurchaseSucceeded = "purchase_succeeded"
|
||||
ProcessStageOrderReview = "order_review"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
var processStageLabels = map[string]string{
|
||||
@@ -115,11 +116,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageManualAction, "关联的蝦皮商品不存在", "open_shopee")
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return stage(ProcessStagePDDUnlinked, "请先关联拼多多商品", "open_mapping")
|
||||
return stage(ProcessStagePDDUnlinked, "请先关联 PDD 商品", processActionOpenPDDLink)
|
||||
}
|
||||
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !ok {
|
||||
return stage(ProcessStagePDDUnlinked, "关联的拼多多商品不存在", "open_mapping")
|
||||
return stage(ProcessStagePDDUnlinked, "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink)
|
||||
}
|
||||
if pdd.Status == "disabled" {
|
||||
return stage(ProcessStageManualAction, "拼多多商品已停用", "open_pdd")
|
||||
|
||||
@@ -19,48 +19,49 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
set func(*batchPreviewDataset, *BatchPreviewItem)
|
||||
name string
|
||||
want string
|
||||
wantAction string
|
||||
set func(*batchPreviewDataset, *BatchPreviewItem)
|
||||
}{
|
||||
{"待人工处理", ProcessStageManualAction, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"待人工处理", ProcessStageManualAction, "reparse", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.sybByID[1]
|
||||
row.ParseStatus = models.SYBParseStatusFailed
|
||||
d.sybByID[1] = row
|
||||
}},
|
||||
{"未关联 PDD", ProcessStagePDDUnlinked, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"未关联 PDD", ProcessStagePDDUnlinked, processActionOpenPDDLink, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.shopeeByID[2]
|
||||
row.PDDProductID = nil
|
||||
d.shopeeByID[2] = row
|
||||
}},
|
||||
{"PDD 待采集", ProcessStagePDDPending, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"PDD 待采集", ProcessStagePDDPending, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
}},
|
||||
{"PDD 采集中", ProcessStagePDDCollecting, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"PDD 采集中", ProcessStagePDDCollecting, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusRunning}
|
||||
}},
|
||||
{"PDD 采集失败", ProcessStagePDDCollectionFail, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"PDD 采集失败", ProcessStagePDDCollectionFail, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusFailed}
|
||||
}},
|
||||
{"颜色待匹配", ProcessStageColorMapping, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
|
||||
{"可创建采购", ProcessStagePurchaseReady, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
|
||||
{"已创建任务", ProcessStageTaskCreated, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
|
||||
{"可创建采购", ProcessStagePurchaseReady, "", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
|
||||
{"已创建任务", ProcessStageTaskCreated, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 11, SYBProductID: &sybID, Status: models.PurchaseTaskStatusRunning}
|
||||
}},
|
||||
{"采购成功", ProcessStagePurchaseSucceeded, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"采购成功", ProcessStagePurchaseSucceeded, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 12, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderCreated}
|
||||
}},
|
||||
{"待人工核对", ProcessStageOrderReview, func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
{"待人工核对", ProcessStageOrderReview, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
d.latestTaskBySYB[1] = models.PurchaseTask{ID: 13, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderResultUnknown}
|
||||
}},
|
||||
@@ -71,8 +72,8 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
preview := BatchPreviewItem{SYBProductID: 1}
|
||||
tt.set(&dataset, &preview)
|
||||
got := processStageFromDataset(1, dataset, preview)
|
||||
if got.Stage != tt.want || got.Label != tt.name {
|
||||
t.Fatalf("got stage=%q label=%q, want %q/%q", got.Stage, got.Label, tt.want, tt.name)
|
||||
if got.Stage != tt.want || got.Label != tt.name || got.NextAction != tt.wantAction {
|
||||
t.Fatalf("got stage=%q label=%q action=%q, want %q/%q/%q", got.Stage, got.Label, got.NextAction, tt.want, tt.name, tt.wantAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -83,3 +84,20 @@ func TestValidProcessStageRejectsInternalOrUnknownValues(t *testing.T) {
|
||||
t.Fatal("process stage validation accepted an internal or unknown value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStageMissingPDDUsesRelinkAction(t *testing.T) {
|
||||
pddID := uint64(3)
|
||||
shopeeID := uint64(2)
|
||||
dataset := batchPreviewDataset{
|
||||
sybByID: map[uint64]models.SYBProduct{1: {ID: 1, ParseStatus: models.SYBParseStatusSuccess, ShopeeProductID: &shopeeID}},
|
||||
shopeeByID: map[uint64]models.ShopeeProduct{2: {ID: 2, PDDProductID: &pddID}},
|
||||
pddByID: map[uint64]models.PDDProduct{},
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
}
|
||||
|
||||
got := processStageFromDataset(1, dataset, BatchPreviewItem{SYBProductID: 1})
|
||||
if got.Stage != ProcessStagePDDUnlinked || got.NextAction != processActionOpenPDDLink || got.Reason != "关联的 PDD 商品不存在,请重新关联" {
|
||||
t.Fatalf("missing PDD did not request relink: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default {
|
||||
created() {
|
||||
this.load()
|
||||
const productId = Number(this.$route.query.productId)
|
||||
if (Number.isInteger(productId) && productId > 0) this.openDetail(productId)
|
||||
if (Number.isInteger(productId) && productId > 0) this.openRouteDetail(productId).catch(() => {})
|
||||
},
|
||||
methods: {
|
||||
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } },
|
||||
@@ -281,6 +281,13 @@ export default {
|
||||
},
|
||||
|
||||
// ---------------- 详情 / 映射 ----------------
|
||||
async openRouteDetail(id) {
|
||||
try {
|
||||
await this.openDetail(id)
|
||||
} finally {
|
||||
if (String(this.$route.query.action || '') === 'link_pdd' && this.detail.product?.id === id) this.openPddPicker('link')
|
||||
}
|
||||
},
|
||||
async openDetail(id) {
|
||||
this.detail = { ...this.emptyDetail(), open: true, loading: true, highlightColor: String(this.$route.query.targetColor || '').trim() }
|
||||
try {
|
||||
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '颜色待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = {}
|
||||
@@ -226,10 +226,11 @@ export default {
|
||||
},
|
||||
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
openShopeeDetail(shopeeProductId, targetColor = '') {
|
||||
openShopeeDetail(shopeeProductId, targetColor = '', action = '') {
|
||||
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
|
||||
const query = { productId: shopeeProductId }
|
||||
if (targetColor) query.targetColor = targetColor
|
||||
if (action) query.action = action
|
||||
const route = this.$router.resolve({ path: '/shopee-products/index', query })
|
||||
window.open(route.href, '_blank', 'noopener')
|
||||
},
|
||||
@@ -240,6 +241,7 @@ export default {
|
||||
},
|
||||
openPurchaseTask(taskId) { this.$router.push({ path: '/purchase-tasks/index', query: { taskId }}) },
|
||||
runPurchaseNextAction(row, readiness) {
|
||||
if (readiness.nextAction === 'open_pdd_link') return this.openShopeeDetail(row.shopeeProductId, '', 'link_pdd')
|
||||
if (readiness.nextAction === 'open_mapping' || readiness.nextAction === 'open_shopee') return this.openShopeeDetail(row.shopeeProductId, row.targetColor)
|
||||
if (readiness.nextAction === 'open_pdd') return this.openPddDetail(readiness.pddProductId)
|
||||
if (readiness.nextAction === 'open_task' && readiness.activeTaskId) return this.openPurchaseTask(readiness.activeTaskId)
|
||||
|
||||
@@ -115,6 +115,31 @@ test('PDD 待采集的下一步打开对应 PDD 商品而不是蝦皮商品', as
|
||||
expect(popup.url()).not.toContain('/shopee-products/index')
|
||||
})
|
||||
|
||||
test('未关联 PDD 显示去关联并直接打开 PDD 商品选择器', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
await context.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 41, orderCode: 'SYB-LINK-41', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '未关联商品', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }, { id: 42, orderCode: 'SYB-MAP-42', shopeeItemId: '26154802795', shopeeProductId: 10, productTitle: '待匹配商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 1200, parseStatus: 'success' }], total: 2, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 41, eligible: false, processStage: 'pdd_unlinked', processStageLabel: '未关联 PDD', processStageReason: '请先关联 PDD 商品', processNextAction: 'open_pdd_link' }, { sybProductId: 42, eligible: false, processStage: 'color_mapping', processStageLabel: '颜色待匹配', processStageReason: '颜色匹配无效,请重新选择', processNextAction: 'open_mapping' }], eligibleCount: 0, skippedCount: 2 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: '26154802794', title: '未关联商品', shopName: '大碼臻選', pddProductId: null, specs: [] } } }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-products/index')
|
||||
await expect(page.getByText('请先关联 PDD 商品', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '去关联', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible()
|
||||
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await page.getByRole('button', { name: '去关联', exact: true }).click()
|
||||
const popup = await popupPromise
|
||||
await expect.poll(() => popup.url()).toContain('/#/shopee-products/index?productId=9&action=link_pdd')
|
||||
await expect(popup.getByText('搜索并选择 PDD 商品', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('SYB 商品页不读取或展示后台同步状态', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let syncRunCalls = 0
|
||||
|
||||
Reference in New Issue
Block a user