From c2c0bae056ed27bbe56643e16efa6b86914a9481 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 15 Sep 2026 10:35:22 +0800 Subject: [PATCH] feat(purchase): surface image-search-linked marker in purchase views (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 5 of #279 required bringing the image-search auto-link annotation into the purchase-facing views, display-only, without gating anything. - BatchPreviewItem and AdminTaskItem now carry imageSearchLinked, sourced read-only from shopee_product.image_search_linked (already loaded in the batch preview dataset; bulk-loaded for the task list/detail views). - Batch purchase preview dialog and purchase task list/detail now show a "图搜未核" tag plus a restrained summary hint when applicable. - Does not touch Eligible/ReasonCode/Reason/NextAction or the #283 mappingTargetsValid gate; regression test confirms an ineligible row's outcome is unchanged when imageSearchLinked is true. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/purchase/admin_query.go | 41 +++++++++ server/app/goauto/purchase/batch.go | 2 + server/app/goauto/purchase/batch_test.go | 85 +++++++++++++++++++ web/src/views/goauto/purchase-tasks/index.vue | 6 +- web/src/views/goauto/syb-products/index.vue | 7 +- 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index 1f71686..d24dac3 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -76,6 +76,7 @@ type AdminTaskItem struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Matching MatchingView `json:"matching"` + ImageSearchLinked bool `json:"imageSearchLinked,omitempty"` } type AdminAttemptItem struct { @@ -163,15 +164,48 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if err != nil { return AdminListResponse{}, err } + imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks) + if err != nil { + return AdminListResponse{}, err + } items := make([]AdminTaskItem, 0, len(tasks)) for _, task := range tasks { item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) item.Matching = matching[task.ID] + if task.ShopeeProductID != nil { + item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID] + } items = append(items, item) } return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } +// loadImageSearchLinked bulk-loads the read-only "linked via image search, not +// manually verified" flag for purchase-task list/detail views (#279 item 5). +// It is display-only and must never influence eligibility or matching. +func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) { + result := make(map[uint64]bool) + ids := make([]uint64, 0, len(tasks)) + seen := make(map[uint64]bool) + for _, task := range tasks { + if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] { + ids = append(ids, *task.ShopeeProductID) + seen[*task.ShopeeProductID] = true + } + } + if len(ids) == 0 { + return result, nil + } + var shopeeProducts []models.ShopeeProduct + if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil { + return nil, internal(err) + } + for _, shopee := range shopeeProducts { + result[shopee.ID] = shopee.ImageSearchLinked + } + return result, nil +} + func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) { var task models.PurchaseTask if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { @@ -203,6 +237,13 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe } item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) item.Matching = matching + imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task}) + if err != nil { + return AdminDetailResponse{}, err + } + if task.ShopeeProductID != nil { + item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID] + } return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 26e872d..17119da 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -40,6 +40,7 @@ type BatchPreviewItem struct { PDDProductID *uint64 `json:"pddProductId,omitempty"` PDDGoodsID string `json:"pddGoodsId,omitempty"` PDDTitle string `json:"pddTitle,omitempty"` + ImageSearchLinked bool `json:"imageSearchLinked,omitempty"` TargetColor string `json:"targetColor,omitempty"` TargetSize string `json:"targetSize,omitempty"` MappedColor string `json:"mappedColor,omitempty"` @@ -388,6 +389,7 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee" return item } + item.ImageSearchLinked = shopee.ImageSearchLinked if !aiConfirmedSpecsCurrent(syb, shopee) { item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse" return item diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index b523198..c362499 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -334,6 +334,91 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t * } } +// #279 item 5: purchase preview must surface (read-only) that a shopee<->PDD +// link was established by image search and not yet human-verified, without +// changing eligibility, reason codes or next actions in any way. +func TestBatchPreviewMarksImageSearchLinkedRowsWithoutAffectingEligibility(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, fixture.pdd.ID) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil { + t.Fatal(err) + } + + response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{ + SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID, + }) + if err != nil { + t.Fatal(err) + } + if len(response.Items) != 1 { + t.Fatalf("unexpected items: %+v", response) + } + item := response.Items[0] + if !item.ImageSearchLinked { + t.Fatalf("expected imageSearchLinked=true for a link created by image search: %+v", item) + } + if !item.Eligible || item.ReasonCode != "" || item.Reason != "" || item.NextAction != "" { + t.Fatalf("imageSearchLinked marker must be display-only and must not change eligibility: %+v", item) + } +} + +func TestBatchPreviewDoesNotMarkManuallyLinkedRows(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, fixture.pdd.ID) + // image_search_linked defaults to false for manually established links. + + response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{ + SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID, + }) + if err != nil { + t.Fatal(err) + } + if len(response.Items) != 1 { + t.Fatalf("unexpected items: %+v", response) + } + if response.Items[0].ImageSearchLinked { + t.Fatalf("manual link must not be marked imageSearchLinked: %+v", response.Items[0]) + } + if !response.Items[0].Eligible { + t.Fatalf("manual link must remain eligible: %+v", response.Items[0]) + } +} + +// Regression: the imageSearchLinked flag must not change any existing +// eligibility outcome, including the well-covered ineligible paths. +func TestBatchPreviewImageSearchLinkedDoesNotChangeIneligibleOutcome(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + failed := fixture.syb + failed.ID = 0 + failed.OrderCode = "SYB-FAILED-IMG" + failed.DetailID++ + failed.ParseStatus = models.SYBParseStatusFailed + failed.TargetColor, failed.TargetSize = "", "" + if err := db.Create(&failed).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil { + t.Fatal(err) + } + + response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{ + SYBProductIDs: []uint64{failed.ID}, DeviceID: &fixture.device.ID, + }) + if err != nil { + t.Fatal(err) + } + if len(response.Items) != 1 { + t.Fatalf("unexpected items: %+v", response) + } + item := response.Items[0] + if item.Eligible || item.ReasonCode != "SYB_PARSE_FAILED" || item.NextAction != "reparse" { + t.Fatalf("image search linkage must not alter the existing ineligible outcome: %+v", item) + } +} + func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) { t.Helper() if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 8643a0d..906404f 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -19,7 +19,7 @@ - + @@ -42,7 +42,7 @@

任务快照

{{ taskTypeLabel(detail.task.taskType) }}{{ detail.task.taskType === 'stock' ? '—' : `SYB-${detail.task.sybProductId || '—'}` }} {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeOrderNoSnapshot || '—') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeItemIdSnapshot || '—') }} - {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}{{ detail.task.pddGoodsIdSnapshot || '—' }} + {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}图搜未核{{ detail.task.pddGoodsIdSnapshot || '—' }} {{ detail.task.pddTitleSnapshot || '未记录' }} {{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }} {{ detail.task.quantity }} 件{{ specSourceLabel(detail.task.specSource) }} 查看映射 @@ -251,5 +251,5 @@ export default { diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index e5055b7..02b7da7 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -78,7 +78,7 @@
-
已选择 {{ purchaseDialog.selectedCount }} 条 SYB 明细其中 {{ purchaseDialog.eligibleCount }} 条适用于采购预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建
+
已选择 {{ purchaseDialog.selectedCount }} 条 SYB 明细其中 {{ purchaseDialog.eligibleCount }} 条适用于采购预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建其中 {{ purchaseImageSearchLinkedCount }} 条为图搜自动关联,尚未人工核对
@@ -92,7 +92,7 @@ 重试 - + @@ -261,6 +261,7 @@ export default { }, computed: { purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) }, + purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length }, canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') }, processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] }, aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) }, @@ -594,7 +595,7 @@ export default { .shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .notice{margin-bottom:16px} .compact-notice{margin-bottom:12px} -.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle} .spec-match-summary{gap:8px 24px} .purchase-note{margin:0 0 16px} .drawer-body{padding:0 4px 24px}