feat: add pending return match batch cancellation #347

This commit is contained in:
QiuSW
2026-09-29 09:40:16 +08:00
parent e7c049dbd6
commit a4c7b5e97b
11 changed files with 584 additions and 54 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ export function confirmReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
}
export function cancelReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
export function cancelReturnMatch(matchId, data, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post', data, ...options })
}
export function remarkReturnMatch(matchId, data) {
+73 -40
View File
@@ -13,7 +13,7 @@
<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 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 || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></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 || 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-form-item>
</div>
<div class="search-row search-row-secondary">
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
@@ -72,7 +72,7 @@
<div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
<el-button v-if="canPurchase" type="danger" link :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
</div>
</template>
<span v-else class="muted">未匹配</span>
@@ -190,6 +190,25 @@
</el-dialog>
<!-- 匹配退货批量结果 -->
<el-dialog v-model="returnCancelResult.open" :title="returnCancelBatchLoading ? '正在取消退货匹配' : '批量取消匹配结果'" width="820px" :close-on-click-modal="false" :close-on-press-escape="!returnCancelBatchLoading" :show-close="!returnCancelBatchLoading">
<div aria-live="polite" class="result-summary">
<span>已处理 {{ returnCancelResult.processed }} / {{ returnCancelResult.total }} 条</span>
<span class="success-text">成功 {{ returnCancelResult.successCount }} 条</span>
<span>跳过 {{ returnCancelResult.skippedCount }} 条</span>
<span class="danger-text">失败 {{ returnCancelResult.failedCount }} 条</span>
</div>
<el-progress v-if="returnCancelBatchLoading" :percentage="returnCancelResult.total ? Math.round(returnCancelResult.processed / returnCancelResult.total * 100) : 0" />
<el-alert v-if="returnCancelResult.refreshError" :title="returnCancelResult.refreshError" type="warning" :closable="false" show-icon />
<p class="muted">仅取消原退货匹配,不修改已有采集或采购任务。失败项仅在当前页仍可取消时保留勾选;不在当前页的失败项仍在下表中。</p>
<el-table :data="returnCancelResult.items" border size="small" max-height="420" empty-text="正在处理,请稍候">
<el-table-column label="SYB 订单" prop="orderCode" min-width="180" />
<el-table-column label="原匹配编号" prop="matchId" width="100" />
<el-table-column label="结果" width="90"><template #default="{ row }">{{ { success: '成功', skipped: '跳过', failed: '失败' }[row.status] }}</template></el-table-column>
<el-table-column label="说明" prop="reason" min-width="280" />
</el-table>
<template #footer><el-button type="primary" :disabled="returnCancelBatchLoading" @click="returnCancelResult.open = false">返回列表</el-button></template>
</el-dialog>
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
@@ -214,7 +233,7 @@
<div class="split">
<div class="split-col">
<h3 class="section-title">SYB 商品</h3>
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
<el-descriptions v-if="matchDetail.data.syb" :column="1" border size="small">
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
@@ -230,7 +249,7 @@
<h3 class="section-title">yeeke 退货商品</h3>
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
<el-descriptions v-if="matchDetail.data.yeeke" :column="1" border size="small">
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
@@ -260,8 +279,8 @@
</div>
<template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
</template>
</el-dialog>
@@ -324,6 +343,7 @@
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link'
import returnCancel from './return-cancel'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
@@ -348,7 +368,7 @@ const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
mixins: [quickLink],
mixins: [quickLink, returnCancel],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
@@ -382,7 +402,7 @@ export default {
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: '已用退货' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
@@ -401,7 +421,7 @@ export default {
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
imageSearchRows() { return this.selectedProducts.filter(row => this.isImageSearchCandidate(row)) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
@@ -428,7 +448,7 @@ export default {
},
created() { this.load() },
methods: {
async load(allowNetworkRetry = true) {
async load(allowNetworkRetry = true, waitForReadiness = false) {
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
const generation = ++this.loadGeneration
@@ -443,8 +463,11 @@ export default {
this.products = r.data.items
this.total = r.data.total
this.loading = false
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation)
void this.loadReturnMatches(this.products.map(item => item.id), generation)
const pending = Promise.all([
this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation),
this.loadReturnMatches(this.products.map(item => item.id), generation)
])
if (waitForReadiness) await pending
} catch (error) {
if (generation !== this.loadGeneration) return
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
@@ -482,13 +505,13 @@ export default {
return values
},
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 },
isAIMatchCandidate(row) { return this.purchaseReady(row).aiMatchEligible === true },
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.eligible === true && ready.processStage === 'purchase_ready' },
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
isAIMatchCandidate(row) { return !this.isActivelyReturnMatched(row) && this.purchaseReady(row).aiMatchEligible === true },
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
@@ -497,7 +520,7 @@ export default {
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
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 ? '正在检查' : '请刷新后重试' } },
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
@@ -527,6 +550,7 @@ export default {
}
this.purchaseReadiness = merged
await this.$nextTick()
if (generation !== this.loadGeneration) return
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))
@@ -562,7 +586,7 @@ export default {
async runBatchSpecMatch() {
if (!this.aiMatchCandidates.length || this.specMatchLoading) return
const selectedIDs = this.selectedProducts.map(row => row.id)
const selectedIDs = this.selectedProducts.filter(row => !this.isActivelyReturnMatched(row)).map(row => row.id)
this.specMatchLoading = true
try {
const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs })
@@ -585,8 +609,10 @@ export default {
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
async loadReturnMatches(ids, generation = this.loadGeneration) {
if (generation !== this.loadGeneration) return
this.returnMatchByProductId = {}
if (!ids.length) return
this.returnMatchLoadError = false
if (!ids.length) { this.returnMatchLoading = false; return }
this.returnMatchLoading = true
try {
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
@@ -598,7 +624,7 @@ export default {
}
this.returnMatchByProductId = map
} catch {
// 展示失败不影响主列表;用户可重新查询重试。
if (generation === this.loadGeneration) this.returnMatchLoadError = true
} finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false
}
@@ -612,7 +638,7 @@ export default {
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
if (!ids.length || this.returnMatchBatchLoading) return
if (!ids.length || this.returnMatchBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
@@ -647,6 +673,7 @@ export default {
await this.load()
},
async saveMatchRemark() {
if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
@@ -657,29 +684,35 @@ export default {
}
},
async confirmMatchDetail() {
try {
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
} catch { return }
await confirmReturnMatch(this.matchDetail.id)
ElMessage.success('已确认匹配')
await this.refreshMatchDetail()
return this.runReturnTransition(async() => {
try {
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
} catch { return }
await confirmReturnMatch(this.matchDetail.id)
ElMessage.success('已确认匹配')
await this.refreshMatchDetail()
})
},
async cancelMatchDetail() {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(this.matchDetail.id)
ElMessage.success('已取消匹配')
this.matchDetail.open = false
await this.load()
return this.runReturnTransition(async() => {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(this.matchDetail.id)
ElMessage.success('已取消匹配')
this.matchDetail.open = false
await this.load()
})
},
async quickCancelMatch(matchId) {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(matchId)
ElMessage.success('已取消匹配')
await this.load()
return this.runReturnTransition(async() => {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(matchId)
ElMessage.success('已取消匹配')
await this.load()
})
},
// ---------------- 批量创建采购任务 ----------------
@@ -0,0 +1,110 @@
import { cancelReturnMatch, getReturnMatch } from '@/api/goauto/return-matches'
const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true }
function isUnauthorized(error) {
return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized'
}
export default {
data() {
return {
returnCancelBatchLoading: false, returnTransitionLoading: false, returnMatchLoadError: false,
returnCancelStopped: false,
returnCancelResult: { open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' }
}
},
computed: {
returnCancelCandidates() { return this.selectedProducts.filter(row => this.isReturnCancelCandidate(row)) },
returnCancelButtonReason() {
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消'
return ''
}
},
beforeUnmount() { this.returnCancelStopped = true; this.loadGeneration++ },
methods: {
isReturnCancelCandidate(row) {
const match = this.returnMatchByProductId[row.id]
return this.purchaseReady(row).processStage === 'return_pending' && match?.status === 'matched' && Number(match.id) > 0
},
async runReturnTransition(action) {
if (this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
this.returnTransitionLoading = true
try { await action() } finally { this.returnTransitionLoading = false }
},
async runBatchCancelReturns() {
if (!this.canPurchase || this.returnCancelButtonReason) return
// Freeze both identities before the confirmation; never follow a later rematch.
const seen = new Set()
const snapshot = this.selectedProducts.filter(row => {
if (seen.has(row.id)) return false
seen.add(row.id); return true
}).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) }))
const count = snapshot.filter(item => item.eligible).length
this.returnCancelBatchLoading = true
try {
try {
await this.$confirm(`取消选中的 ${count} 条退货匹配?取消后商品将重新计算采购准备状态。`, '批量取消匹配', { type: 'warning', confirmButtonText: '确认取消匹配', cancelButtonText: '返回', confirmButtonClass: 'el-button--danger' })
} catch { return }
if (this.returnCancelStopped) return
this.returnCancelResult = { open: true, items: [], total: snapshot.length, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' }
const result = this.returnCancelResult
let authExpired = false
for (const original of snapshot) {
const item = { ...original, status: 'skipped', reason: '', retryable: false }
if (!original.eligible) item.reason = '非退货待确认的已匹配商品,跳过'
else if (authExpired || this.returnCancelStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
try {
const response = await cancelReturnMatch(original.matchId, { expectedStatus: 'matched' }, requestOptions)
// The shared interceptor returns false for the legacy 6401 login expiry.
if (response === false) throw new Error('Unauthorized')
item.status = 'success'; item.reason = '已取消匹配,重新计算采购准备状态'
} catch (error) {
if (isUnauthorized(error)) {
authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查结果'
} else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') {
item.reason = '原匹配状态已变化,未取消'
} else {
item.status = 'failed'
item.reason = error?.response ? '取消失败,请刷新后检查原匹配' : '网络异常,取消结果未确认'
// Read-only recovery; never repeat an uncertain POST automatically.
try {
const response = await getReturnMatch(original.matchId, requestOptions)
if (response === false) throw new Error('Unauthorized')
const match = response.data?.match
if (match?.status === 'cancelled' || match?.status === 'confirmed') {
item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'cancelled' ? '取消' : '确认'},未重复操作`
} else {
item.retryable = match?.status === 'matched' && Number(match.id) === Number(original.matchId) && Number(match.sybProductId) === Number(original.sybProductId)
if (item.retryable) item.reason += ';原匹配仍待确认,可手动重试'
}
} catch (readError) {
if (isUnauthorized(readError)) authExpired = true
item.reason += ';回读失败,请刷新检查,不自动重试'
}
}
}
}
result.items.push(item); result.processed++
result[`${item.status}Count`]++
}
if (this.returnCancelStopped || authExpired) return
const generation = this.loadGeneration + 1
try {
await this.load(false, true)
if (generation !== this.loadGeneration || this.returnCancelStopped) return
if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) {
result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return
}
const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId]))
await this.$nextTick()
if (generation !== this.loadGeneration || this.returnCancelStopped) return
this.$refs.productTable?.clearSelection()
this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
} catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复取消' }
} finally { this.returnCancelBatchLoading = false }
}
}
}