SYB 商品
-
+
{{ matchDetail.data.syb.orderCode }}
{{ matchDetail.data.syb.shopeeItemId }}
{{ matchDetail.data.syb.shopName }}
@@ -225,7 +268,7 @@
yeeke 退货商品
-
+
{{ matchDetail.data.yeeke.orderSn }}
{{ matchDetail.data.yeeke.itemId }}
{{ matchDetail.data.yeeke.variationId }}
@@ -242,7 +285,7 @@
备注
- 保存备注
+ 保存备注
操作日志
@@ -255,8 +298,8 @@
关闭
- 确认
- 取消匹配
+ 确认
+ 取消匹配
@@ -319,6 +362,8 @@
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 returnConfirm from './return-confirm'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
@@ -343,7 +388,7 @@ const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
- mixins: [quickLink],
+ mixins: [quickLink, returnCancel, returnConfirm],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
@@ -359,7 +404,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
- query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
+ query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -374,10 +419,10 @@ 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') },
+ 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 匹配资格'
@@ -396,7 +441,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 },
@@ -423,7 +468,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
@@ -432,13 +477,17 @@ export default {
this.$refs.productTable?.clearSelection()
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
- 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 }, requestOptions)
+ 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)
if (generation !== this.loadGeneration) return
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) {
@@ -452,7 +501,7 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
- reset() { this.query = { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
+ reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -476,13 +525,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'])
@@ -491,7 +540,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)}` },
@@ -521,6 +570,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))
@@ -556,7 +606,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 })
@@ -579,8 +629,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 })
@@ -592,7 +644,7 @@ export default {
}
this.returnMatchByProductId = map
} catch {
- // 展示失败不影响主列表;用户可重新查询重试。
+ if (generation === this.loadGeneration) this.returnMatchLoadError = true
} finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false
}
@@ -606,7 +658,7 @@ export default {
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
- if (!ids.length || this.returnMatchBatchLoading) return
+ if (!ids.length || this.returnMatchBatchLoading || this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
@@ -641,6 +693,7 @@ export default {
await this.load()
},
async saveMatchRemark() {
+ if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
@@ -651,29 +704,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()
+ })
},
// ---------------- 批量创建采购任务 ----------------
@@ -856,10 +915,12 @@ export default {
-
diff --git a/web/src/views/goauto/syb-products/return-cancel.js b/web/src/views/goauto/syb-products/return-cancel.js
new file mode 100644
index 0000000..c894718
--- /dev/null
+++ b/web/src/views/goauto/syb-products/return-cancel.js
@@ -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.returnConfirmBatchLoading || 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.returnConfirmBatchLoading || 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 }
+ }
+ }
+}
diff --git a/web/src/views/goauto/syb-products/return-confirm.js b/web/src/views/goauto/syb-products/return-confirm.js
new file mode 100644
index 0000000..2cbc7f1
--- /dev/null
+++ b/web/src/views/goauto/syb-products/return-confirm.js
@@ -0,0 +1,102 @@
+import { confirmReturnMatch, 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'
+}
+const emptyResult = () => ({ open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' })
+
+export default {
+ data() {
+ return { returnConfirmBatchLoading: false, returnConfirmStopped: false, returnConfirmResult: emptyResult() }
+ },
+ computed: {
+ returnConfirmCandidates() { return this.returnConfirmSnapshot().filter(item => item.eligible) },
+ returnConfirmButtonReason() {
+ if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
+ if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
+ if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
+ if (!this.returnConfirmCandidates.length) return '请勾选当前页退货待确认的已匹配商品;已用退货不参与批量使用'
+ return ''
+ }
+ },
+ beforeUnmount() { this.returnConfirmStopped = true },
+ methods: {
+ returnConfirmSnapshot() {
+ const current = new Set(this.products.map(row => row.id))
+ const seen = new Set()
+ return this.selectedProducts.filter(row => {
+ if (!current.has(row.id) || 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) }))
+ },
+ async runBatchConfirmReturns() {
+ if (!this.canPurchase || this.returnConfirmButtonReason) return
+ // Freeze original identities before asking. Never follow a later replacement match.
+ const snapshot = this.returnConfirmSnapshot()
+ const count = snapshot.filter(item => item.eligible).length
+ this.returnConfirmBatchLoading = true
+ try {
+ try {
+ await this.$confirm(`确认使用选中的 ${count} 条退货商品?确认后将标记为已用退货,不再创建采购任务。不修改已有采购任务,也不会自动让 Yeeke 发货。`, '使用退货', { type: 'warning', confirmButtonText: '确认使用', cancelButtonText: '返回' })
+ } catch { return }
+ if (this.returnConfirmStopped) return
+ this.returnConfirmResult = { ...emptyResult(), open: true, total: snapshot.length }
+ const result = this.returnConfirmResult
+ 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.returnConfirmStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
+ try {
+ const response = await confirmReturnMatch(original.matchId, requestOptions)
+ 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 ? '使用失败,请刷新检查原匹配' : '网络异常,使用结果未确认'
+ // Unknown write outcomes are recovered by reading only, not by repeating POST.
+ try {
+ const response = await getReturnMatch(original.matchId, requestOptions)
+ if (response === false) throw new Error('Unauthorized')
+ const match = response.data?.match
+ const sameOriginal = Number(match?.id) === Number(original.matchId) && Number(match?.sybProductId) === Number(original.sybProductId)
+ if (sameOriginal && ['confirmed', 'cancelled'].includes(match.status)) {
+ item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'confirmed' ? '确认使用' : '取消'},未重复操作`
+ } else {
+ item.retryable = sameOriginal && match.status === 'matched'
+ item.reason += item.retryable ? ';原匹配仍待确认,可手动重试' : ';原匹配无法确认,请刷新检查'
+ }
+ } catch (readError) {
+ if (isUnauthorized(readError)) authExpired = true
+ item.reason += ';回读失败,请刷新检查,不自动重试'
+ }
+ }
+ }
+ }
+ result.items.push(item); result.processed++
+ result[`${item.status}Count`]++
+ }
+ if (this.returnConfirmStopped || authExpired) return
+ const generation = this.loadGeneration + 1
+ try {
+ await this.load(false, true)
+ if (generation !== this.loadGeneration || this.returnConfirmStopped) 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.returnConfirmStopped) 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.returnConfirmBatchLoading = false }
+ }
+ }
+}
diff --git a/web/src/views/goauto/syb-sync-runs/index.vue b/web/src/views/goauto/syb-sync-runs/index.vue
index 845e413..6b70031 100644
--- a/web/src/views/goauto/syb-sync-runs/index.vue
+++ b/web/src/views/goauto/syb-sync-runs/index.vue
@@ -89,7 +89,7 @@ export default {
}
},
computed: {
- canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
+ canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue
index 714a9c9..427e592 100644
--- a/web/src/views/goauto/yeeke-returns/index.vue
+++ b/web/src/views/goauto/yeeke-returns/index.vue
@@ -172,24 +172,17 @@ export default {
{ label: '退货待确认', value: 'matched' },
{ label: '已用退货', value: 'confirmed' }
],
- // yeeke's only confirmed claim-status value is the raw string "1"
- // (HAR evidence, #336 requirement doc). Any other value the sync sees
- // is preserved verbatim and flagged status_unrecognized — never
- // guessed into a label here. "unknown" is a server-side pseudo-filter
- // matching status_unrecognized=true, not a real yeeke value.
+ // HAR-confirmed business statuses; unknown remains a server-side filter.
claimStatusOptions: [
- { label: '已认领', value: '1' },
+ { label: '待出货', value: '1' },
+ { label: '已重出', value: '2' },
{ label: '未知(原样展示)', value: 'unknown' }
]
}
},
created() { this.load(); this.loadSummary(); this.loadShops() },
methods: {
- // "1" is yeeke's only confirmed known status; anything else reaching
- // this function (status_unrecognized=false from the server) is an
- // unmapped value we have not seen yet — show it as-is rather than a
- // bare digit or a guessed Chinese label.
- claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
+ claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } },
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
diff --git a/web/src/views/goauto/yeeke-sync-runs/index.vue b/web/src/views/goauto/yeeke-sync-runs/index.vue
index 1084c04..0c23a25 100644
--- a/web/src/views/goauto/yeeke-sync-runs/index.vue
+++ b/web/src/views/goauto/yeeke-sync-runs/index.vue
@@ -71,7 +71,7 @@ export default {
}
},
computed: {
- canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
+ canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load() },
diff --git a/web/tests/e2e/syb-return-cancel.spec.ts b/web/tests/e2e/syb-return-cancel.spec.ts
new file mode 100644
index 0000000..aa841ed
--- /dev/null
+++ b/web/tests/e2e/syb-return-cancel.spec.ts
@@ -0,0 +1,108 @@
+import { expect, test, type Page, type BrowserContext } from '@playwright/test'
+
+const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
+async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
+ await page.setViewportSize({ width: 1920, height: 1080 })
+ await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
+ const writes: number[] = []
+ const cancelled = new Set
()
+ const lists: URL[] = []
+ await page.route('**/api/**', async route => {
+ const url = new URL(route.request().url()); const pathname = url.pathname
+ if (pathname.startsWith('/src/api/')) return route.continue()
+ const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
+ if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
+ if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
+ if (pathname.endsWith('/api/admin/v1/syb-products')) {
+ lists.push(url)
+ const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !cancelled.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
+ return reply({ items, total: items.length })
+ }
+ if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: cancelled.has(id) ? 'pdd_unlinked' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: cancelled.has(id) ? '未关联 PDD' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
+ if (pathname.endsWith('/return-matches')) {
+ if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
+ return reply({ items: [1, 2, 3].filter(id => !cancelled.has(id)).map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
+ }
+ if (/\/return-matches\/\d+\/cancel$/.test(pathname)) {
+ const id = Number(pathname.split('/').at(-2)); writes.push(id)
+ expect(route.request().postDataJSON()).toEqual({ expectedStatus: 'matched' })
+ if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟取消失败' } })
+ if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
+ if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
+ await new Promise(resolve => setTimeout(resolve, 150))
+ cancelled.add(id - 10); return reply({ item: { id, status: 'cancelled' } })
+ }
+ if (/\/return-matches\/\d+$/.test(pathname)) {
+ const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: 'matched' }, logs: [] })
+ }
+ return reply({ items: [], total: 0 })
+ })
+ await page.goto('/#/syb-products')
+ await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
+ return { writes, lists }
+}
+
+test('one confirmation, mixed selection, progress, success and filter retention', async ({ page, context }) => {
+ const { writes, lists } = await setup(page, context)
+ const cancel = page.getByRole('button', { name: /取消匹配\s*0/ }).first()
+ await expect(cancel).toBeDisabled()
+ await page.locator('thead .el-checkbox').first().click()
+ await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
+ await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
+ await expect(page.getByText('取消选中的 2 条退货匹配?取消后商品将重新计算采购准备状态。', { exact: true })).toBeVisible()
+ await page.getByRole('button', { name: '返回', exact: true }).click()
+ expect(writes).toEqual([])
+ await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
+ await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
+ await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
+ await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(writes).toEqual([11, 12])
+ expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
+})
+
+for (const mode of ['partial', 'conflict', 'auth']) {
+ test(`${mode}: per-row outcome, failed selection and stop on login failure`, async ({ page, context }) => {
+ const { writes } = await setup(page, context, mode)
+ await page.locator('thead .el-checkbox').first().click()
+ await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
+ await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
+ await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ if (mode === 'partial') {
+ await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
+ }
+ })
+}
+
+test('match load failure disables bulk cancellation', async ({ page, context }) => {
+ await setup(page, context, 'metadata-error')
+ await page.locator('thead .el-checkbox').first().click()
+ await expect(page.getByRole('button', { name: /取消匹配\s*0/ }).first()).toBeDisabled()
+})
+test('unprivileged role cannot see cancellation or selection', async ({ page, context }) => {
+ await setup(page, context, 'success', 'viewer')
+ await expect(page.getByRole('button', { name: /取消匹配/ })).toHaveCount(0)
+ await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
+})
+
+test('return-pending filter retained; successful rows leave current page', async ({ page, context }) => {
+ const { lists } = await setup(page, context)
+ await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
+ await page.getByRole('option', { name: '退货待确认', exact: true }).click()
+ await page.getByRole('button', { name: '查询', exact: true }).click()
+ await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
+ await page.locator('thead .el-checkbox').first().click()
+ await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
+ await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
+})
diff --git a/web/tests/e2e/syb-return-confirm.spec.ts b/web/tests/e2e/syb-return-confirm.spec.ts
new file mode 100644
index 0000000..1d0d9ea
--- /dev/null
+++ b/web/tests/e2e/syb-return-confirm.spec.ts
@@ -0,0 +1,117 @@
+import { expect, test, type Page, type BrowserContext } from '@playwright/test'
+
+const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
+async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
+ await page.setViewportSize({ width: 1920, height: 1080 })
+ await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
+ const writes: number[] = []
+ const confirmed = new Set()
+ const lists: URL[] = []
+ await page.route('**/api/**', async route => {
+ const url = new URL(route.request().url()); const pathname = url.pathname
+ if (pathname.startsWith('/src/api/')) return route.continue()
+ const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
+ if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
+ if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
+ if (pathname.endsWith('/api/admin/v1/syb-products')) {
+ lists.push(url)
+ const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !confirmed.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
+ return reply({ items, total: items.length })
+ }
+ if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: confirmed.has(id) ? 'return_used' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: confirmed.has(id) ? '已用退货' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
+ if (pathname.endsWith('/return-matches')) {
+ if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
+ return reply({ items: [1, 2, 3].map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 && !confirmed.has(id) ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
+ }
+ if (/\/return-matches\/\d+\/confirm$/.test(pathname)) {
+ const id = Number(pathname.split('/').at(-2)); writes.push(id)
+ expect(route.request().method()).toBe('POST')
+ if (mode === 'readback-confirmed') return route.fulfill({ status: 504, json: { code: 'TIMEOUT' } })
+ if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟使用失败' } })
+ if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
+ if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
+ await new Promise(resolve => setTimeout(resolve, 150))
+ confirmed.add(id - 10); return reply({ item: { id, status: 'confirmed' } })
+ }
+ if (/\/return-matches\/\d+$/.test(pathname)) {
+ const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: mode === 'readback-confirmed' || confirmed.has(id - 10) ? 'confirmed' : 'matched' }, logs: [] })
+ }
+ return reply({ items: [], total: 0 })
+ })
+ await page.goto('/#/syb-products')
+ await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
+ return { writes, lists }
+}
+
+test('v2 button order, one confirmation, no write on return, progress and mutual exclusion', async ({ page, context }) => {
+ const { writes } = await setup(page, context)
+ const use = page.getByRole('button', { name: /使用退货\s*0/ })
+ await expect(use).toBeDisabled()
+ const toolbar = await page.locator('.toolbar-actions button').allTextContents()
+ expect(toolbar.findIndex(t => t.includes('匹配退货'))).toBeLessThan(toolbar.findIndex(t => t.includes('取消匹配')))
+ expect(toolbar.findIndex(t => t.includes('取消匹配'))).toBeLessThan(toolbar.findIndex(t => t.includes('使用退货')))
+ await page.locator('thead .el-checkbox').first().click()
+ await page.getByRole('button', { name: /使用退货\s*2/ }).click()
+ await expect(page.getByText(/不会自动让 Yeeke 发货/, { exact: false }).last()).toBeVisible()
+ await page.getByRole('button', { name: '返回', exact: true }).click()
+ expect(writes).toEqual([])
+ await page.getByRole('button', { name: /使用退货\s*2/ }).click()
+ await page.getByRole('button', { name: '确认使用', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
+ await expect(page.getByRole('button', { name: /取消匹配\s*2/ }).first()).toBeDisabled()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(writes).toEqual([11, 12])
+ await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
+ await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
+ await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
+})
+
+for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
+ test(`${mode}: independent outcomes, failed selection, auth stop and readback without retry`, async ({ page, context }) => {
+ const { writes } = await setup(page, context, mode)
+ await page.locator('thead .el-checkbox').first().click()
+ await page.getByRole('button', { name: /使用退货\s*2/ }).click()
+ await page.getByRole('button', { name: '确认使用', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
+ const summary = mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : mode === 'conflict' ? '跳过 2 条' : '跳过 3 条'
+ await expect(page.getByText(summary, { exact: true })).toBeVisible()
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
+ if (mode === 'partial') {
+ await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
+ }
+ })
+}
+
+test('metadata load error disables use', async ({ page, context }) => {
+ await setup(page, context, 'metadata-error')
+ await page.locator('thead .el-checkbox').first().click()
+ await expect(page.getByRole('button', { name: /使用退货\s*0/ })).toBeDisabled()
+})
+
+test('viewer cannot see use button or selection', async ({ page, context }) => {
+ await setup(page, context, 'success', 'viewer')
+ await expect(page.getByRole('button', { name: /使用退货/ })).toHaveCount(0)
+ await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
+})
+
+test('pending filter and page size retained; used rows disappear', async ({ page, context }) => {
+ const { lists } = await setup(page, context)
+ await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
+ await page.getByRole('option', { name: '退货待确认', exact: true }).click()
+ await page.getByRole('button', { name: '查询', exact: true }).click()
+ await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
+ await page.locator('thead .el-checkbox').first().click()
+ await page.getByRole('button', { name: /使用退货\s*2/ }).click()
+ await page.getByRole('button', { name: '确认使用', exact: true }).click()
+ await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
+ expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
+ expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
+ await page.getByRole('button', { name: '返回列表', exact: true }).click()
+ await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
+ await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
+})
diff --git a/web/tests/unit/syb-return-cancel.test.cjs b/web/tests/unit/syb-return-cancel.test.cjs
new file mode 100644
index 0000000..54dc9b5
--- /dev/null
+++ b/web/tests/unit/syb-return-cancel.test.cjs
@@ -0,0 +1,111 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/return-cancel.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
+const tick = () => new Promise(resolve => setImmediate(resolve))
+function instance(overrides = {}) {
+ const writes = []; const reads = []; const selected = []; const confirmations = []
+ const dependencies = {
+ cancelReturnMatch: async (id, body) => { writes.push({ id, body }) },
+ getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
+ }
+ const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
+ const vm = { ...options.methods, ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
+ products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
+ purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
+ returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
+ purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
+ $confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
+ $refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
+ load: async function() { this.loadGeneration++ }
+ }
+ vm.selectedProducts = [...vm.products]
+ Object.entries(options.computed).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
+ return { vm, options, writes, reads, selected, confirmations }
+}
+test('matched pending count only; serial requests include frozen matched precondition and skip confirmed', async () => {
+ const { vm, writes, confirmations } = instance()
+ assert.equal(vm.returnCancelCandidates.length, 2)
+ await vm.runBatchCancelReturns()
+ assert.deepEqual(writes, [{ id: 11, body: { expectedStatus: 'matched' } }, { id: 12, body: { expectedStatus: 'matched' } }])
+ assert.match(confirmations[0], /2 条/)
+ assert.equal(vm.returnCancelResult.successCount, 2); assert.equal(vm.returnCancelResult.skippedCount, 1)
+ assert.equal(vm.returnCancelBatchLoading, false)
+})
+test('permission, empty, metadata failure/loading and conflicting actions disable entry', async () => {
+ for (const [key, value] of [['canPurchase', false], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true]]) {
+ const { vm, writes } = instance(); vm[key] = value; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
+ }
+ const { vm, writes } = instance(); vm.selectedProducts = []; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
+})
+test('confirmation dismissal does not write', async () => {
+ const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchCancelReturns()
+ assert.equal(writes.length, 0); assert.equal(vm.returnCancelResult.open, false); assert.equal(vm.returnCancelBatchLoading, false)
+})
+test('freeze identities, dedupe, prevent duplicate execution and serialize', async () => {
+ let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
+ const { vm } = instance({ cancelReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
+ vm.selectedProducts.push(vm.products[0]); const running = vm.runBatchCancelReturns(); await tick()
+ vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchCancelReturns()
+ assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
+})
+test('partial failure retains only readback-confirmed eligible failed row', async () => {
+ const { vm, selected, reads } = instance({ cancelReturnMatch: async id => { if (id === 12) throw { response: { status: 403 } } } })
+ await vm.runBatchCancelReturns()
+ assert.equal(vm.returnCancelResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
+})
+test('state conflict skips and never recancels original or replacement', async () => {
+ const { vm, reads } = instance({ cancelReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
+ await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.equal(reads.length, 0)
+})
+test('uncertain timeout reads original once, no blind POST retry', async () => {
+ let writes = 0
+ const { vm, reads, selected } = instance({ cancelReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, status: 'cancelled' } } } } })
+ await vm.runBatchCancelReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12]); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.deepEqual(selected, [])
+})
+test('failed readback leaves uncertain results unselected', async () => {
+ const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('offline') }, getReturnMatch: async () => { throw new Error('offline') } })
+ await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
+})
+for (const error of [new Error('Unauthorized'), { response: { status: 401 } }]) {
+ test(`auth failure stops remaining writes (${error.message || 'HTTP'})`, async () => {
+ let writes = 0; const { vm } = instance({ cancelReturnMatch: async () => { writes++; throw error } })
+ await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.failedCount, 2); assert.equal(vm.returnCancelBatchLoading, false)
+ })
+}
+test('new query generation does not restore stale selection', async () => {
+ const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
+ vm.load = async () => { vm.loadGeneration += 2 }; await vm.runBatchCancelReturns(); assert.deepEqual(selected, [])
+})
+test('filter removes failed row but keeps failure result; refresh failure preserves result', async () => {
+ const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
+ vm.load = async () => { vm.loadGeneration++; vm.products = [] }; await vm.runBatchCancelReturns()
+ assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
+ vm.products = [{ id: 1 }]; vm.selectedProducts = vm.products; vm.load = async () => { throw new Error('offline') }; await vm.runBatchCancelReturns()
+ assert.match(vm.returnCancelResult.refreshError, /刷新失败/)
+})
+test('unmount prevents further writes after in-flight request', async () => {
+ let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
+ const { vm, options } = instance({ cancelReturnMatch: async () => { writes++; await wait } })
+ const running = vm.runBatchCancelReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
+ assert.equal(writes, 1); assert.equal(vm.returnCancelBatchLoading, false)
+})
+
+test('legacy interceptor false/6401 result is auth failure, not cancellation success', async () => {
+ let writes = 0
+ const { vm } = instance({ cancelReturnMatch: async () => { writes++; return false } })
+ await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.successCount, 0); assert.equal(vm.returnCancelResult.failedCount, 2)
+})
+
+test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => {
+ const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
+ const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {})
+ const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' }
+ const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 }
+ const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true }
+ assert.equal(vm.isSelectableCandidate(row), true)
+ for (const method of ['isPurchaseCandidate', 'isCollectionCandidate', 'isAIMatchCandidate', 'isImageSearchCandidate', 'isReturnMatchCandidate']) assert.equal(vm[method](row), false, method)
+ ready.processStage = 'return_used'; assert.equal(vm.isSelectableCandidate(row), false)
+ ready.processStage = 'purchase_ready'; assert.equal(vm.isPurchaseCandidate(row), true)
+})
diff --git a/web/tests/unit/syb-return-confirm.test.cjs b/web/tests/unit/syb-return-confirm.test.cjs
new file mode 100644
index 0000000..97f08ee
--- /dev/null
+++ b/web/tests/unit/syb-return-confirm.test.cjs
@@ -0,0 +1,112 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const readOptions = (file, dependencies) => new Function(...Object.keys(dependencies), fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products', file), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return'))(...Object.values(dependencies))
+const tick = () => new Promise(resolve => setImmediate(resolve))
+function instance(overrides = {}) {
+ const writes = []; const reads = []; const selected = []; const confirmations = []
+ const dependencies = {
+ confirmReturnMatch: async (id, options) => { writes.push({ id, options }); return { code: 200 } },
+ getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
+ }
+ const options = readOptions('return-confirm.js', dependencies)
+ const cancel = readOptions('return-cancel.js', { cancelReturnMatch: async () => {}, getReturnMatch: dependencies.getReturnMatch })
+ const vm = { ...cancel.methods, ...options.methods, ...cancel.data(), ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
+ products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
+ purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
+ returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
+ purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
+ $confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
+ $refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
+ load: async function() { this.loadGeneration++ }
+ }
+ vm.selectedProducts = [...vm.products]
+ Object.entries({ ...cancel.computed, ...options.computed }).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
+ return { vm, options, writes, reads, selected, confirmations }
+}
+test('current-page count, deduplication, mixed rows, original confirm API and one confirmation', async () => {
+ const { vm, writes, confirmations } = instance()
+ vm.selectedProducts.push(vm.products[0], { id: 99 })
+ assert.equal(vm.returnConfirmCandidates.length, 2)
+ await vm.runBatchConfirmReturns()
+ assert.deepEqual(writes.map(w => w.id), [11, 12])
+ assert.ok(writes.every(w => w.options.suppressErrorMessage && w.options.suppressNetworkError))
+ assert.equal(confirmations.length, 1); assert.match(confirmations[0], /2 条/); assert.match(confirmations[0], /不会自动让 Yeeke 发货/)
+ assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(vm.returnConfirmResult.skippedCount, 1)
+ assert.equal(vm.returnConfirmBatchLoading, false)
+})
+test('permission, metadata, busy and empty states prohibit writes', async () => {
+ for (const [key, value] of [['canPurchase', false], ['loading', true], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true], ['returnCancelBatchLoading', true]]) {
+ const { vm, writes } = instance(); vm[key] = value; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0, key)
+ }
+ const { vm, writes } = instance(); vm.matchDetail.saving = true; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
+ vm.matchDetail.saving = false; vm.selectedProducts = []; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
+})
+test('return dismisses without a write or result; confirm busy blocks cancel and single transitions', async () => {
+ const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchConfirmReturns()
+ assert.equal(writes.length, 0); assert.equal(vm.returnConfirmResult.open, false); assert.equal(vm.returnConfirmBatchLoading, false)
+ vm.returnConfirmBatchLoading = true; let ran = false
+ assert.match(vm.returnCancelButtonReason, /正在处理/)
+ await vm.runReturnTransition(async () => { ran = true }); assert.equal(ran, false)
+})
+test('serial, frozen identities and no duplicate execution while waiting', async () => {
+ let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
+ const { vm } = instance({ confirmReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
+ const running = vm.runBatchConfirmReturns(); await tick()
+ vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchConfirmReturns()
+ assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
+})
+test('partial business failure keeps only readback-proven current-page failed selection', async () => {
+ const { vm, selected, reads } = instance({ confirmReturnMatch: async id => { if (id === 12) throw { response: { status: 500 } } } })
+ await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
+})
+test('state conflict skips original; neither readback nor replacement write', async () => {
+ const { vm, reads } = instance({ confirmReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
+ await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.skippedCount, 3); assert.equal(reads.length, 0)
+})
+for (const status of ['confirmed', 'cancelled', 'matched']) {
+ test(`unknown outcome reads original ${status} once, never resends`, async () => {
+ let writes = 0
+ const { vm, reads, selected } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status } } } } })
+ await vm.runBatchConfirmReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12])
+ assert.equal(vm.returnConfirmResult.successCount, 0)
+ assert.deepEqual(selected, status === 'matched' ? [1, 2] : [])
+ assert.equal(vm.returnConfirmResult.skippedCount, status === 'matched' ? 1 : 3)
+ })
+}
+test('unreadable/mismatched original never treated as confirmed or retryable', async () => {
+ for (const getReturnMatch of [async () => { throw new Error('offline') }, async id => ({ data: { match: { id: id + 1, sybProductId: 999, status: 'confirmed' } } })]) {
+ const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') }, getReturnMatch })
+ await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
+ }
+})
+for (const response of [false, new Error('Unauthorized'), { response: { status: 401 } }]) {
+ test(`auth failure/legacy false stops remaining writes (${String(response)})`, async () => {
+ let writes = 0
+ const { vm } = instance({ confirmReturnMatch: async () => { writes++; if (response === false) return false; throw response } })
+ await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.equal(vm.returnConfirmBatchLoading, false)
+ assert.match(vm.returnConfirmResult.items[1].reason, /未继续执行/)
+ })
+}
+test('login failure during readback stops remaining POSTs', async () => {
+ let writes = 0
+ const { vm } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async () => { throw new Error('Unauthorized') } })
+ await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2)
+})
+test('new generation, replaced original and vanished failed row cannot restore old selection', async () => {
+ for (const load of [async function() { this.loadGeneration += 2 }, async function() { this.loadGeneration++; this.products = [] }, async function() { this.loadGeneration++; this.returnMatchByProductId[1].id = 99; this.returnMatchByProductId[2].id = 98 }]) {
+ const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') } }); vm.load = load
+ await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
+ }
+})
+test('refresh failure keeps completed result and does not write again', async () => {
+ const { vm, writes } = instance(); vm.load = async () => { throw new Error('offline') }; await vm.runBatchConfirmReturns()
+ assert.match(vm.returnConfirmResult.refreshError, /刷新失败/); assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(writes.length, 2)
+})
+test('unmount stops remaining writes after in-flight response', async () => {
+ let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
+ const { vm, options } = instance({ confirmReturnMatch: async () => { writes++; await wait } })
+ const running = vm.runBatchConfirmReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
+ assert.equal(writes, 1); assert.equal(vm.returnConfirmBatchLoading, false)
+})
diff --git a/web/tests/unit/yeeke-status.test.cjs b/web/tests/unit/yeeke-status.test.cjs
new file mode 100644
index 0000000..428b0cc
--- /dev/null
+++ b/web/tests/unit/yeeke-status.test.cjs
@@ -0,0 +1,27 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8')
+const script = file.match(/