feat(syb): #340 phase 2 — merge main, recompute samples, frontend UI

Merge origin/main (through #345/e7c049d) into feat/340-syb-excluded-products.
Resolved conflicts in sybimport/service.go, handler.go, service_test.go
(kept #342's createdFrom/createdTo AND #340's purchaseType, all combined
with processStage), and took origin/main's syb-products/index.vue as the
base for the new UI work below. Renamed the migration version file from
1789801100000 to 1789801500000 (next free slot after main's highest,
1789801400000) — content unchanged, version comes from the filename.

Backend: sybproductfilter recompute preview now also returns up to 20
sample rows (order code, shopee item id, change direction, matched rule)
alongside the existing counts; execute stays count + audit-log only.

Frontend (feat/340 issue "## 设计证据"/"## 页面", prototype v1):
- SYB 订单商品页: 采购类型 filter (需 PDD 采购 default / 无需 PDD 采购 / 全部),
  处理阶段 gains 无需采购 (pdd_excluded); selecting 退货待确认/已用退货/无需采购
  auto-switches 采购类型 to 全部; pdd_excluded rows are tickable only for
  匹配退货 (never collection/purchase/AI-match/image-search); rows show the
  stage tag plus 规则:<kind> <keyword>; detail drawer shows 采购类型 and rule.
- SYB 过滤规则页: 跳过导入 -> 标记为无需 PDD 采购 wording, 上次同步命中 ->
  上次同步标记, rewritten scope note, admin-only 按当前规则重算 button with
  preview dialog (4 counts + up to 20 samples) -> confirm -> execute.
- SYB 同步记录: 结构/关键词过滤命中 -> 结构/关键词过滤标记 (see #340 point 8;
  the run-level "其中无需采购 N 条" count is derivable client-side from the
  existing char/keyword counts, no backend field added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-29 10:59:12 +08:00
co-authored by Claude Opus 5.5
parent e55645ff66
commit 316d5aa412
5 changed files with 274 additions and 26 deletions
@@ -22,11 +22,32 @@ type RecomputeCounts struct {
type recomputeChange struct {
id uint64
orderCode string
shopeeItemID string
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
ruleID *uint64
ruleKind, ruleKeyword string
}
// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
// changes are shown so an admin can sanity-check the run before executing it.
type RecomputeSample struct {
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
// Direction is "excluded_to_pdd" or "pdd_to_excluded".
Direction string `json:"direction"`
// RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
// rule matches any more).
RuleKind string `json:"ruleKind,omitempty"`
RuleKeyword string `json:"ruleKeyword,omitempty"`
}
const (
DirectionExcludedToPDD = "excluded_to_pdd"
DirectionPDDToExcluded = "pdd_to_excluded"
maxRecomputeSamples = 20
)
// recomputeChanges computes, against the CURRENT enabled rules, every
// syb_product row whose mark should flip, skipping any row that has ever had
// a purchase task or currently has an active return match — those never
@@ -76,7 +97,7 @@ func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []reco
counts.SkippedReturnMatch++
continue
}
change := recomputeChange{id: row.ID, toExcluded: wouldExclude}
change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
if wouldExclude {
ruleID := rule.ID
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
@@ -103,14 +124,36 @@ func recomputeVariationSku(rawJSON string) string {
return value
}
// RecomputePreview is read-only: it must produce exactly the counts execute
// would produce.
func (s *Service) RecomputePreview(ctx context.Context) (RecomputeCounts, error) {
counts, _, err := recomputeChanges(ctx, s.DB)
// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
// execute will produce, plus up to maxRecomputeSamples sample rows so an
// admin can see what would actually change before confirming.
type RecomputePreviewResult struct {
RecomputeCounts
Samples []RecomputeSample `json:"samples"`
}
// RecomputePreview is read-only: its counts must exactly match what execute
// would produce (both are computed by the same recomputeChanges call).
func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
counts, changes, err := recomputeChanges(ctx, s.DB)
if err != nil {
return RecomputeCounts{}, internal(err)
return RecomputePreviewResult{}, internal(err)
}
return counts, nil
samples := make([]RecomputeSample, 0, maxRecomputeSamples)
for _, change := range changes {
if len(samples) >= maxRecomputeSamples {
break
}
sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
if change.toExcluded {
sample.Direction = DirectionPDDToExcluded
sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
} else {
sample.Direction = DirectionExcludedToPDD
}
samples = append(samples, sample)
}
return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples}, nil
}
type RecomputeExecuteResult struct {
@@ -2,6 +2,7 @@ package sybproductfilter
import (
"context"
"fmt"
"testing"
"go-admin/app/goauto/models"
@@ -65,8 +66,31 @@ func TestRecomputePreviewMatchesExecute(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.RecomputeCounts != preview {
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview, result.RecomputeCounts)
if result.RecomputeCounts != preview.RecomputeCounts {
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
}
if len(preview.Samples) != 2 {
t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
}
var sawPDDToExcluded, sawExcludedToPDD bool
for _, sample := range preview.Samples {
switch sample.Direction {
case DirectionPDDToExcluded:
sawPDDToExcluded = true
if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
}
case DirectionExcludedToPDD:
sawExcludedToPDD = true
if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
}
default:
t.Fatalf("unexpected direction: %+v", sample)
}
}
if !sawPDDToExcluded || !sawExcludedToPDD {
t.Fatalf("expected both directions represented: %+v", preview.Samples)
}
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
@@ -105,3 +129,31 @@ func TestRecomputePreviewMatchesExecute(t *testing.T) {
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
}
}
func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 25; i++ {
row := models.SYBProduct{
OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
RawJSON: `{"variationSku":"档口-x"}`,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 25 {
t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
}
if len(preview.Samples) != 20 {
t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
}
}
@@ -4,3 +4,5 @@ export function listSybProductFilters(params) { return request({ url: base, meth
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
export function previewSybProductFilterRecompute() { return request({ url: `${base}/recompute/preview`, method: 'get' }) }
export function executeSybProductFilterRecompute() { return request({ url: `${base}/recompute/execute`, method: 'post' }) }
@@ -28,8 +28,15 @@
/>
<section>
<h3>结构过滤(不可增删,仅可停用)</h3>
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被当成档口商品不入库;只包含其中一个的会正常入库。需要变更判据请另行建单评估。</p>
<div class="section-head">
<h3>结构过滤(不可增删,仅可停用)</h3>
<el-tooltip v-if="isAdmin" :disabled="!recomputeButtonReason" :content="recomputeButtonReason" placement="bottom">
<span>
<el-button :disabled="Boolean(recomputeButtonReason)" @click="openRecompute">按当前规则重算</el-button>
</span>
</el-tooltip>
</div>
<p class="muted">variationSku 需要<strong>同时包含</strong>下方列出的每一个字符,才会被标记为无需 PDD 采购;只包含其中一个的仍按需要 PDD 采购处理。需要变更判据请另行建单评估。</p>
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
<el-table-column label="必需字符" prop="keyword" width="180" />
<el-table-column label="状态" width="150">
@@ -47,7 +54,7 @@
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上次同步命中" width="150">
<el-table-column label="上次同步标记" width="150">
<template #default="{ row }">{{ hitText(row) }}</template>
</el-table-column>
<el-table-column label="最近变更" min-width="240">
@@ -101,7 +108,7 @@
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上次同步命中" width="150">
<el-table-column label="上次同步标记" width="150">
<template #default="{ row }">{{ hitText(row) }}</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
@@ -116,7 +123,7 @@
</el-table>
</section>
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
<p class="scope-note">停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。</p>
</el-card>
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
@@ -140,6 +147,57 @@
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="recompute.open" title="按当前规则重算" width="720px" :close-on-click-modal="false" @closed="resetRecompute">
<div v-loading="recompute.loading">
<el-alert
v-if="recompute.error"
:title="recompute.error"
type="error"
:closable="false"
show-icon
class="notice"
/>
<template v-else-if="recompute.loaded">
<el-alert
title="重算只按当前启用规则重新判定标记;已有采购任务或正处于有效退货匹配的商品不会被改变。"
type="info"
:closable="false"
show-icon
class="notice"
/>
<div class="recompute-counts">
<div class="count-item"><div class="count-value">{{ recompute.result.pddToExcluded }}</div><div class="count-label">将标记为无需 PDD 采购</div></div>
<div class="count-item"><div class="count-value">{{ recompute.result.excludedToPdd }}</div><div class="count-label">将恢复为需要 PDD 采购</div></div>
<div class="count-item"><div class="count-value">{{ recompute.result.skippedHasTask }}</div><div class="count-label">已有采购任务,跳过</div></div>
<div class="count-item"><div class="count-value">{{ recompute.result.skippedReturnMatch }}</div><div class="count-label">有效退货匹配中,跳过</div></div>
</div>
<p v-if="recomputeTotalChanges === 0" class="muted">按当前规则重算,没有需要变更的商品。</p>
<template v-else>
<p class="muted">变更示例(最多显示 20 条,实际变更数为 {{ recomputeTotalChanges }} 条):</p>
<el-table :data="recompute.result.samples" border size="small" empty-text="无样例">
<el-table-column label="订单号" prop="orderCode" min-width="140" />
<el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="120" />
<el-table-column label="变更方向" width="150">
<template #default="{ row }">{{ row.direction === 'pdd_to_excluded' ? '需采购 → 无需采购' : '无需采购 → 需采购' }}</template>
</el-table-column>
<el-table-column label="命中规则" min-width="160">
<template #default="{ row }">{{ row.ruleKeyword ? `${row.ruleKind === 'char' ? '结构' : '关键词'}:${row.ruleKeyword}` : '(不再命中任何规则)' }}</template>
</el-table-column>
</el-table>
</template>
</template>
</div>
<template #footer>
<el-button :disabled="recompute.executing" @click="recompute.open = false">取消</el-button>
<el-button
type="primary"
:loading="recompute.executing"
:disabled="!recompute.loaded || Boolean(recompute.error) || recomputeTotalChanges === 0"
@click="confirmRecompute"
>确认执行</el-button>
</template>
</el-dialog>
</template>
</BasicLayout>
</template>
@@ -150,9 +208,15 @@ import {
listSybProductFilters,
createSybProductFilter,
setSybProductFilterEnabled,
deleteSybProductFilter
deleteSybProductFilter,
previewSybProductFilterRecompute,
executeSybProductFilterRecompute
} from '@/api/goauto/syb-product-filters'
function emptyRecomputeResult() {
return { pddToExcluded: 0, excludedToPdd: 0, skippedHasTask: 0, skippedReturnMatch: 0, samples: [] }
}
export default {
name: 'GoAutoSybProductFilters',
data() {
@@ -161,12 +225,26 @@ export default {
chars: [],
keywords: [],
query: { keyword: '' },
editor: { open: false, saving: false, keyword: '', note: '' }
editor: { open: false, saving: false, keyword: '', note: '' },
recompute: {
open: false,
loading: false,
executing: false,
loaded: false,
error: '',
result: emptyRecomputeResult()
}
}
},
computed: {
isAdmin() {
return (this.$store.getters.roles || []).includes('admin')
},
recomputeButtonReason() {
return this.isAdmin ? '' : '只有管理员可以重算'
},
recomputeTotalChanges() {
return this.recompute.result.pddToExcluded + this.recompute.result.excludedToPdd
}
},
created() {
@@ -210,7 +288,7 @@ export default {
async confirmDisableChar(row) {
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
await ElMessageBox.confirm(
`停用后,SYB 同步将不再过滤任何档口商品(结构过滤只有这一条)。按最近一次同步数据估算,${scale}明细会恢复入库。`,
`停用后,SYB 同步将不再把任何档口商品标记为无需 PDD 采购(结构过滤只有这一条)。这只影响之后的同步,${scale}已标记的明细不会自动变化,需要使用「按当前规则重算」才会更新。`,
`确认停用结构过滤「${row.keyword}」?`,
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
@@ -267,6 +345,35 @@ export default {
await deleteSybProductFilter(row.id)
ElMessage.success('关键词已删除')
this.load()
},
resetRecompute() {
this.recompute = { open: false, loading: false, executing: false, loaded: false, error: '', result: emptyRecomputeResult() }
},
async openRecompute() {
this.recompute.open = true
this.recompute.loading = true
this.recompute.loaded = false
this.recompute.error = ''
try {
const res = await previewSybProductFilterRecompute()
this.recompute.result = { ...emptyRecomputeResult(), ...(res.data || {}) }
this.recompute.loaded = true
} catch (e) {
this.recompute.error = (e && e.message) || '预览失败,请重试'
} finally {
this.recompute.loading = false
}
},
async confirmRecompute() {
this.recompute.executing = true
try {
await executeSybProductFilterRecompute()
ElMessage.success('已按当前规则重新计算')
this.recompute.open = false
this.load()
} finally {
this.recompute.executing = false
}
}
}
}
@@ -312,4 +419,26 @@ h3 {
.scope-note {
margin-top: 12px;
}
.recompute-counts {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.count-item {
flex: 1;
text-align: center;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
}
.count-value {
font-size: 22px;
font-weight: 600;
color: #303133;
}
.count-label {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>
+31 -9
View File
@@ -12,7 +12,8 @@
</el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
@@ -37,6 +38,7 @@
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
<div v-if="purchaseReady(row).processStage === 'pdd_excluded'" class="muted">规则:{{ excludedRuleText(row) }}</div>
<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)">
@@ -326,6 +328,10 @@
<el-descriptions-item label="解析状态"><el-tag :type="parseMeta(detail.item.parseStatus).type">{{ parseMeta(detail.item.parseStatus).label }}</el-tag></el-descriptions-item>
<el-descriptions-item label="解析说明">{{ detail.item.parseNote || '—' }}</el-descriptions-item>
<el-descriptions-item label="人工确认">{{ detail.item.manuallyConfirmed ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="采购类型">
<el-tag :type="detail.item.pddPurchaseExcluded ? 'info' : 'success'">{{ detail.item.pddPurchaseExcluded ? '无需 PDD 采购' : '需 PDD 采购' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="detail.item.pddPurchaseExcluded" label="命中规则">{{ excludedRuleText(detail.item) }}</el-descriptions-item>
</el-descriptions>
<div class="drawer-actions">
<el-button @click="openReparseSingle">重新解析</el-button>
@@ -404,7 +410,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -420,7 +426,7 @@ export default {
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') || roles.includes('after_sales') },
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: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
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: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
@@ -478,7 +484,7 @@ export default {
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -501,7 +507,15 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
// 的 AND 组合出空列表;用户仍可再手动切回。
onProcessStageChange(value) {
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
this.query.purchaseType = 'all'
}
},
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -531,10 +545,13 @@ export default {
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
return participating.has(this.purchaseReady(row).processStage)
},
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
@@ -543,8 +560,13 @@ export default {
isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
// #340: 规则快照固定在标记时刻,与当前规则是否被改名/停用无关。
excludedRuleText(item) {
if (!item || !item.excludedRuleKeyword) return '(规则快照缺失)'
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
},
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' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
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' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
// #339: the page can now show up to 500 rows, but the preview endpoint
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still