feat: batch AI match SYB purchase specs (#188)
This commit is contained in:
@@ -3,6 +3,7 @@ import request from '@/utils/request'
|
||||
export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
|
||||
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
|
||||
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
|
||||
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
|
||||
export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data, timeout: 60000 }) }
|
||||
export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) }
|
||||
export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) }
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<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="uncertain" /><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 class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购({{ purchaseCandidates.length }})</el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建 PDD 采集({{ collectionCandidates.length }})</el-button></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-button v-if="canPurchase" :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="可同时选择可创建采购与 PDD 待采集明细;两个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-alert v-if="canPurchase" title="可同时选择待 AI 匹配、可创建采购与 PDD 待采集明细;三个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -90,6 +90,22 @@
|
||||
<template #footer><template v-if="collectionBatch.step === 'confirm'"><el-button @click="collectionBatch.open = false">取消</el-button><el-button type="primary" :loading="collectionBatch.saving" :disabled="collectionBatch.products.length === 0" @click="submitCollectionBatch">创建 {{ collectionBatch.products.length }} 个采集任务</el-button></template><el-button v-else type="primary" @click="collectionBatch.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="specMatchResult.open" title="AI 匹配结果" width="820px" :close-on-click-modal="false" @closed="refreshAfterSpecMatch">
|
||||
<div class="purchase-summary spec-match-summary" aria-live="polite">
|
||||
<span class="success-text">自动确认 <strong>{{ specMatchResult.autoConfirmedCount }}</strong> 条</span>
|
||||
<span>待人工 <strong>{{ specMatchResult.pendingCount }}</strong> 条</span>
|
||||
<span v-if="specMatchResult.failedCount" class="danger-text">失败 <strong>{{ specMatchResult.failedCount }}</strong> 条</span>
|
||||
<span v-if="specMatchResult.skippedCount">跳过 <strong>{{ specMatchResult.skippedCount }}</strong> 条</span>
|
||||
</div>
|
||||
<el-table :data="specMatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
|
||||
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ row.orderCode || `ID ${row.sybProductId}` }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="specMatchStatusMeta(row.status).type" effect="plain">{{ specMatchStatusMeta(row.status).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="置信度" width="90"><template #default="{ row }">{{ formatConfidence(row.confidence) }}</template></el-table-column>
|
||||
<el-table-column label="说明" min-width="300"><template #default="{ row }">{{ row.reason }}</template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
@@ -151,7 +167,7 @@ import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
@@ -165,11 +181,12 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
purchaseReadiness: {}, purchaseReadinessLoading: false, loadGeneration: 0,
|
||||
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
|
||||
purchaseDevices: [],
|
||||
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
|
||||
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
|
||||
collectionBatch: this.emptyCollectionBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
@@ -183,6 +200,7 @@ export default {
|
||||
computed: {
|
||||
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).processStage === 'color_mapping') },
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
@@ -248,13 +266,16 @@ export default {
|
||||
handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' },
|
||||
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
|
||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && (this.isPurchaseCandidate(row) || this.isCollectionCandidate(row)) },
|
||||
isAIMatchCandidate(row) { return this.purchaseReady(row).processStage === 'color_mapping' },
|
||||
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(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 ? '正在检查' : '请刷新后重试' } },
|
||||
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_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) {
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
||||
this.purchaseReadiness = {}
|
||||
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
||||
this.purchaseReadinessLoading = true
|
||||
@@ -263,11 +284,9 @@ export default {
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
|
||||
await this.$nextTick()
|
||||
const valid = this.selectedProducts.filter(row => this.rowSelectable(row))
|
||||
if (valid.length !== this.selectedProducts.length) {
|
||||
this.$refs.productTable?.clearSelection()
|
||||
valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
}
|
||||
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
||||
this.$refs.productTable?.clearSelection()
|
||||
valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
} catch {
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = Object.fromEntries(ids.map(id => [id, { sybProductId: id, eligible: false, reason: '采购准备检查失败,请点击刷新重试', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '采购准备检查失败,请点击刷新重试', processNextAction: 'refresh' }]))
|
||||
@@ -298,6 +317,25 @@ export default {
|
||||
if (readiness.nextAction === 'refresh') return this.load()
|
||||
},
|
||||
|
||||
async runBatchSpecMatch() {
|
||||
if (!this.aiMatchCandidates.length || this.specMatchLoading) return
|
||||
const selectedIDs = this.selectedProducts.map(row => row.id)
|
||||
this.specMatchLoading = true
|
||||
try {
|
||||
const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs })
|
||||
this.specMatchResult = { open: true, selectedIDs, ...response.data }
|
||||
} finally {
|
||||
this.specMatchLoading = false
|
||||
}
|
||||
},
|
||||
async refreshAfterSpecMatch() {
|
||||
await this.loadPurchaseReadiness(this.products.map(item => item.id), {}, this.loadGeneration, new Set(this.specMatchResult.selectedIDs))
|
||||
},
|
||||
specMatchStatusMeta(status) {
|
||||
return { auto_confirmed: { label: '已确认', type: 'success' }, pending: { label: '待人工', type: 'warning' }, failed: { label: '失败', type: 'danger' }, skipped: { label: '已跳过', type: 'info' }}[status] || { label: status || '未知', type: 'info' }
|
||||
},
|
||||
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
|
||||
|
||||
// ---------------- 批量创建采购任务 ----------------
|
||||
async openSinglePurchase(row) {
|
||||
this.$refs.productTable?.clearSelection()
|
||||
@@ -427,6 +465,8 @@ export default {
|
||||
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.toolbar-actions :deep(.el-form-item__content){gap:8px}
|
||||
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
|
||||
.action-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;margin-left:6px;padding:0 5px;border-radius:9px;background:#eef2f7;color:#606266;font-size:11px;font-variant-numeric:tabular-nums;line-height:18px}
|
||||
.action-count-primary{background:rgba(255,255,255,.2);color:inherit}
|
||||
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
|
||||
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
|
||||
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
|
||||
@@ -437,6 +477,7 @@ export default {
|
||||
.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}
|
||||
.spec-match-summary{gap:8px 24px}
|
||||
.purchase-note{margin:0 0 16px}
|
||||
.drawer-body{padding:0 4px 24px}
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
|
||||
Reference in New Issue
Block a user