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
+7 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 04cc54a554fc3bdc30273e6a5ace5fff5d95386f wiki_revision: 0e0ad0b3312c524d90e063167dd2e945fb22b0e9
synchronized_at: 2026-09-28T08:28:30Z synchronized_at: 2026-09-29T01:36:01Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -556,3 +556,8 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。 - server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。 - web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。 - 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
## SYB 批量取消退货匹配(#347)
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 57ae18796e441eeed7b0ffc2a833fe6b4b5055f1 wiki_revision: e95c53cec27080e073e6f7a4aaed46d5421dddf5
synchronized_at: 2026-09-28T08:28:33Z synchronized_at: 2026-09-29T01:36:04Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -716,3 +716,9 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。 - 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。 - 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。 - 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
## SYB 当前页批量取消匹配(#347)
- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
+11 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 wiki_revision: fff074b921368be646cda1bf0a434608fc5082c8
synchronized_at: 2026-09-21T08:15:05Z synchronized_at: 2026-09-29T01:36:20Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -1399,3 +1399,12 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。 GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。 匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
## 管理端退货匹配取消前提(#347)
此为 Admin 共享管理接口补充,不改变 Android Agent 契约。
`POST /api/admin/v1/return-matches/:id/cancel` 可携带 `{"expectedStatus":"matched"}`。仅允许此值;其他值(含空字符串、null)或错误 JSON 返回 HTTP 400 / `INVALID_REQUEST`。省略请求体或属性兼容旧单条取消。
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
@@ -0,0 +1,120 @@
package returnmatch
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func seedCancelMatch(t *testing.T, db *gorm.DB, status string) models.ReturnMatch {
t.Helper()
syb := seedSYB(t, db, "CANCEL-TEST", 1, "白色", "L", time.Now())
item := seedReturn(t, db, "白色,L", nil)
m := models.ReturnMatch{SYBProductID: syb.ID, YeekeReturnItemID: item.ID, ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &item.ID, Status: status, MatchedAt: time.Now()}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
return m
}
func TestCancelExpectedStatusHTTP(t *testing.T) {
for _, tc := range []struct {
name, status, body, role string
code int
}{
{"matched", "matched", `{"expectedStatus":"matched"}`, "purchaser", 200},
{"confirmed_conflict", "confirmed", `{"expectedStatus":"matched"}`, "after_sales", 409},
{"cancelled_conflict", "cancelled", `{"expectedStatus":"matched"}`, "admin", 409},
{"legacy_empty", "confirmed", "", "after_sales", 200},
{"legacy_object", "confirmed", `{}`, "admin", 200},
{"invalid_status", "matched", `{"expectedStatus":"confirmed"}`, "admin", 400},
{"invalid_empty", "matched", `{"expectedStatus":""}`, "admin", 400},
{"invalid_null", "matched", `{"expectedStatus":null}`, "admin", 400},
{"malformed", "matched", `{`, "admin", 400},
{"forbidden", "matched", `{"expectedStatus":"matched"}`, "other", 403},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, tc.status)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/cancel", m.ID), strings.NewReader(tc.body))
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "cancel-tester"})
Handler{DB: db}.Cancel(c)
if w.Code != tc.code {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
var logs int64
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
if tc.code == 200 {
if saved.Status != "cancelled" || saved.ActiveSYBProductID != nil || saved.ActiveYeekeReturnItemID != nil || saved.CancelledBy != "cancel-tester" || saved.CancelledAt == nil || logs != 1 {
t.Fatalf("cancel or audit incomplete: %+v logs=%d", saved, logs)
}
_, err := NewService(db).Cancel(context.Background(), m.ID, "repeat", "matched")
if err != errStateConflict {
t.Fatalf("repeat err=%v", err)
}
} else if saved.Status != tc.status || logs != 0 {
t.Fatalf("rejected transition mutated state: %+v logs=%d", saved, logs)
}
})
}
}
func TestCancelExpectedStatusMissingAndConfirmedRace(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.Cancel(context.Background(), 99999, "tester", "matched"); err != gorm.ErrRecordNotFound {
t.Fatalf("missing err=%v", err)
}
m := seedCancelMatch(t, db, "matched")
if _, err := s.Confirm(context.Background(), m.ID, "reviewer"); err != nil {
t.Fatal(err)
}
// A batch holding the earlier matched snapshot must not undo a completed confirmation.
if _, err := s.Cancel(context.Background(), m.ID, "batch", "matched"); err != errStateConflict {
t.Fatalf("stale batch err=%v", err)
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
if saved.Status != "confirmed" || saved.ActiveSYBProductID == nil {
t.Fatalf("confirmation lost: %+v", saved)
}
if _, err := s.Cancel(context.Background(), m.ID, "legacy"); err != nil {
t.Fatalf("legacy confirmed cancellation: %v", err)
}
}
func TestCancelAuditFailureRollsBackTransition(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, "matched")
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_cancel_log", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
tx.AddError(errors.New("synthetic audit failure"))
}
}); err != nil {
t.Fatal(err)
}
if _, err := NewService(db).Cancel(context.Background(), m.ID, "tester", "matched"); err == nil {
t.Fatal("expected audit failure")
}
var saved models.ReturnMatch
if err := db.First(&saved, m.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Status != "matched" || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || saved.CancelledAt != nil {
t.Fatalf("failed audit did not roll back: %+v", saved)
}
}
+19 -1
View File
@@ -1,7 +1,9 @@
package returnmatch package returnmatch
import ( import (
"encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
@@ -140,7 +142,19 @@ func (h Handler) Confirm(c *gin.Context) {
func (h Handler) Cancel(c *gin.Context) { func (h Handler) Cancel(c *gin.Context) {
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) { h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
return s.Cancel(ctx.Request.Context(), id, operator) var body map[string]json.RawMessage
if err := ctx.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
raw, supplied := body["expectedStatus"]
if !supplied {
return s.Cancel(ctx.Request.Context(), id, operator)
}
var expectedStatus string
if err := json.Unmarshal(raw, &expectedStatus); err != nil {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
return s.Cancel(ctx.Request.Context(), id, operator, expectedStatus)
}) })
} }
@@ -160,6 +174,10 @@ func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint
_, operator := operatorFromContext(c) _, operator := operatorFromContext(c)
match, err := fn(NewService(db), c, id, operator) match, err := fn(NewService(db), c, id, operator)
if err != nil { if err != nil {
if errors.Is(err, errInvalidExpectedStatus) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "expectedStatus 只允许 matched,或省略请求体"})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
return return
+15 -5
View File
@@ -414,11 +414,18 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string)
return match, err return match, err
} }
// Cancel restores the SYB product to purchasable (by clearing // Cancel releases the active SYB product and return item pointers; actual
// ActiveSYBProductID) and returns the return item to the eligible pool (by // purchase readiness and return-pool eligibility are then re-evaluated.
// clearing ActiveYeekeReturnItemID), from either matched or confirmed state, // Without a precondition it accepts either matched or confirmed state,
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许. // per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { var errInvalidExpectedStatus = errors.New("expectedStatus must be matched")
// Optional matched precondition is checked under the same row lock as cancellation.
// Legacy callers without it retain the confirmed-to-cancelled transition.
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string, expectedStatus ...string) (models.ReturnMatch, error) {
if len(expectedStatus) > 1 || (len(expectedStatus) == 1 && expectedStatus[0] != models.ReturnMatchStatusMatched) {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
var match models.ReturnMatch var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil { if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
@@ -427,6 +434,9 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
if match.Status == models.ReturnMatchStatusCancelled { if match.Status == models.ReturnMatchStatusCancelled {
return errStateConflict return errStateConflict
} }
if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) {
return errStateConflict
}
now := s.Now() now := s.Now()
match.Status = models.ReturnMatchStatusCancelled match.Status = models.ReturnMatchStatusCancelled
match.ActiveSYBProductID = nil match.ActiveSYBProductID = nil
@@ -436,7 +446,7 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
if err := tx.Save(&match).Error; err != nil { if err := tx.Save(&match).Error; err != nil {
return err return err
} }
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,释放 SYB 与退货商品占用,采购准备状态重新计算"}).Error
}) })
return match, err return match, err
} }
+2 -2
View File
@@ -16,8 +16,8 @@ export function confirmReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' }) return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
} }
export function cancelReturnMatch(matchId) { export function cancelReturnMatch(matchId, data, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' }) return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post', data, ...options })
} }
export function remarkReturnMatch(matchId, data) { 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-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.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item> <el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><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>
<div class="search-row search-row-secondary"> <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> <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"> <div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button> <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="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> </div>
</template> </template>
<span v-else class="muted">未匹配</span> <span v-else class="muted">未匹配</span>
@@ -190,6 +190,25 @@
</el-dialog> </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"> <el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite"> <div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span> <span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
@@ -214,7 +233,7 @@
<div class="split"> <div class="split">
<div class="split-col"> <div class="split-col">
<h3 class="section-title">SYB 商品</h3> <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="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</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> <el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
@@ -230,7 +249,7 @@
<h3 class="section-title">yeeke 退货商品</h3> <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 && 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-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="退货订单号">{{ 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.itemId }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item> <el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
@@ -260,8 +279,8 @@
</div> </div>
<template #footer> <template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button> <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 === '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" @click="cancelMatchDetail">取消匹配</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> </template>
</el-dialog> </el-dialog>
@@ -324,6 +343,7 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue' import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link' import quickLink from './quick-link'
import returnCancel from './return-cancel'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products' import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops' import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices' import { listDevices } from '@/api/goauto/devices'
@@ -348,7 +368,7 @@ const RETURN_MATCH_BATCH_MAX = 500
export default { export default {
name: 'GoAutoSybProducts', name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
mixins: [quickLink], mixins: [quickLink, returnCancel],
setup() { return { Search, RefreshLeft, ArrowDown } }, setup() { return { Search, RefreshLeft, ArrowDown } },
data() { data() {
return { 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') }, canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] }, processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) }, 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 }, aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() { aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
@@ -401,7 +421,7 @@ export default {
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。 // #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX }, collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' }, 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), // 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。 // 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS }, imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
@@ -428,7 +448,7 @@ export default {
}, },
created() { this.load() }, created() { this.load() },
methods: { methods: {
async load(allowNetworkRetry = true) { async load(allowNetworkRetry = true, waitForReadiness = false) {
const orderCodes = this.normalizedOrderCodes() const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return if (orderCodes === null) return
const generation = ++this.loadGeneration const generation = ++this.loadGeneration
@@ -443,8 +463,11 @@ export default {
this.products = r.data.items this.products = r.data.items
this.total = r.data.total this.total = r.data.total
this.loading = false this.loading = false
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) const pending = Promise.all([
void this.loadReturnMatches(this.products.map(item => item.id), generation) 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) { } catch (error) {
if (generation !== this.loadGeneration) return if (generation !== this.loadGeneration) return
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
@@ -482,13 +505,13 @@ export default {
return values return values
}, },
handleSelectionChange(rows) { this.selectedProducts = rows }, handleSelectionChange(rows) { this.selectedProducts = rows },
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' }, 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 ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, 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.purchaseReady(row).aiMatchEligible === true }, isAIMatchCandidate(row) { return !this.isActivelyReturnMatched(row) && this.purchaseReady(row).aiMatchEligible === true },
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件 // 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品 // 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。 // 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) }, isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。 // #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) { isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready']) const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
@@ -497,7 +520,7 @@ export default {
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' }, isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates // `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。 // / 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) }, 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 ? '正在检查' : '请刷新后重试' } }, 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)}` }, 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 this.purchaseReadiness = merged
await this.$nextTick() await this.$nextTick()
if (generation !== this.loadGeneration) return
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row)) const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
this.$refs.productTable?.clearSelection() this.$refs.productTable?.clearSelection()
valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
@@ -562,7 +586,7 @@ export default {
async runBatchSpecMatch() { async runBatchSpecMatch() {
if (!this.aiMatchCandidates.length || this.specMatchLoading) return 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 this.specMatchLoading = true
try { try {
const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs }) const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs })
@@ -585,8 +609,10 @@ export default {
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() }, isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status }, matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
async loadReturnMatches(ids, generation = this.loadGeneration) { async loadReturnMatches(ids, generation = this.loadGeneration) {
if (generation !== this.loadGeneration) return
this.returnMatchByProductId = {} this.returnMatchByProductId = {}
if (!ids.length) return this.returnMatchLoadError = false
if (!ids.length) { this.returnMatchLoading = false; return }
this.returnMatchLoading = true this.returnMatchLoading = true
try { try {
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true }) const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
@@ -598,7 +624,7 @@ export default {
} }
this.returnMatchByProductId = map this.returnMatchByProductId = map
} catch { } catch {
// 展示失败不影响主列表;用户可重新查询重试。 if (generation === this.loadGeneration) this.returnMatchLoadError = true
} finally { } finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false if (generation === this.loadGeneration) this.returnMatchLoading = false
} }
@@ -612,7 +638,7 @@ export default {
}, },
async runBatchMatchReturns() { async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds 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 this.returnMatchBatchLoading = true
try { try {
const r = await batchMatchReturns({ sybProductIds: ids }) const r = await batchMatchReturns({ sybProductIds: ids })
@@ -647,6 +673,7 @@ export default {
await this.load() await this.load()
}, },
async saveMatchRemark() { async saveMatchRemark() {
if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
this.matchDetail.saving = true this.matchDetail.saving = true
try { try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText }) await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
@@ -657,29 +684,35 @@ export default {
} }
}, },
async confirmMatchDetail() { async confirmMatchDetail() {
try { return this.runReturnTransition(async() => {
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) try {
} catch { return } await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
await confirmReturnMatch(this.matchDetail.id) } catch { return }
ElMessage.success('已确认匹配') await confirmReturnMatch(this.matchDetail.id)
await this.refreshMatchDetail() ElMessage.success('已确认匹配')
await this.refreshMatchDetail()
})
}, },
async cancelMatchDetail() { async cancelMatchDetail() {
try { return this.runReturnTransition(async() => {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) try {
} catch { return } await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
await cancelReturnMatch(this.matchDetail.id) } catch { return }
ElMessage.success('已取消匹配') await cancelReturnMatch(this.matchDetail.id)
this.matchDetail.open = false ElMessage.success('已取消匹配')
await this.load() this.matchDetail.open = false
await this.load()
})
}, },
async quickCancelMatch(matchId) { async quickCancelMatch(matchId) {
try { return this.runReturnTransition(async() => {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) try {
} catch { return } await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
await cancelReturnMatch(matchId) } catch { return }
ElMessage.success('已取消匹配') await cancelReturnMatch(matchId)
await this.load() 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 }
}
}
}
+108
View File
@@ -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<number>()
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()
})
+111
View File
@@ -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('<script>')[1].split('</script>')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const options = new Function('quickLink', 'returnCancel', '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)
})