From a4c7b5e97b40cef14df5fd028310b83e05b0fa9f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 29 Sep 2026 09:40:16 +0800 Subject: [PATCH 1/3] feat: add pending return match batch cancellation #347 --- docs/02-architecture-and-code-map.md | 9 +- docs/03-business-rules-and-glossary.md | 10 +- docs/08-agent-api-contract.md | 13 +- .../returnmatch/cancel_precondition_test.go | 120 ++++++++++++++++++ server/app/goauto/returnmatch/handler.go | 20 ++- server/app/goauto/returnmatch/service.go | 20 ++- web/src/api/goauto/return-matches.js | 4 +- web/src/views/goauto/syb-products/index.vue | 113 +++++++++++------ .../goauto/syb-products/return-cancel.js | 110 ++++++++++++++++ web/tests/e2e/syb-return-cancel.spec.ts | 108 ++++++++++++++++ web/tests/unit/syb-return-cancel.test.cjs | 111 ++++++++++++++++ 11 files changed, 584 insertions(+), 54 deletions(-) create mode 100644 server/app/goauto/returnmatch/cancel_precondition_test.go create mode 100644 web/src/views/goauto/syb-products/return-cancel.js create mode 100644 web/tests/e2e/syb-return-cancel.spec.ts create mode 100644 web/tests/unit/syb-return-cancel.test.cjs diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 583f595..d76ae51 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 04cc54a554fc3bdc30273e6a5ace5fff5d95386f -synchronized_at: 2026-09-28T08:28:30Z +wiki_revision: 0e0ad0b3312c524d90e063167dd2e945fb22b0e9 +synchronized_at: 2026-09-29T01:36:01Z @@ -556,3 +556,8 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。 - web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。 - 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。 +## SYB 批量取消退货匹配(#347) + +- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。 +- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。 +- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 5a3b637..4aa9fb6 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 57ae18796e441eeed7b0ffc2a833fe6b4b5055f1 -synchronized_at: 2026-09-28T08:28:33Z +wiki_revision: e95c53cec27080e073e6f7a4aaed46d5421dddf5 +synchronized_at: 2026-09-29T01:36:04Z @@ -716,3 +716,9 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 - 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。 - 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。 - 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。 +## SYB 当前页批量取消匹配(#347) + +- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。 +- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。 +- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。 +- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 5b7b7e7..ec3818a 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 -synchronized_at: 2026-09-21T08:15:05Z +wiki_revision: fff074b921368be646cda1bf0a434608fc5082c8 +synchronized_at: 2026-09-29T01:36:20Z @@ -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 合法值及其他参数行为保持不变。 匹配成功回填的历史任务,不额外要求当前采购状态为 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 重新查询替代匹配并取消。 diff --git a/server/app/goauto/returnmatch/cancel_precondition_test.go b/server/app/goauto/returnmatch/cancel_precondition_test.go new file mode 100644 index 0000000..d269a9e --- /dev/null +++ b/server/app/goauto/returnmatch/cancel_precondition_test.go @@ -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) + } +} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 4f950b8..281e508 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -1,7 +1,9 @@ package returnmatch import ( + "encoding/json" "errors" + "io" "net/http" "strconv" @@ -140,7 +142,19 @@ func (h Handler) Confirm(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) { - 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) match, err := fn(NewService(db), c, id, operator) 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) { c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) return diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 8709d20..7ff8fca 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -414,11 +414,18 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) return match, err } -// Cancel restores the SYB product to purchasable (by clearing -// ActiveSYBProductID) and returns the return item to the eligible pool (by -// clearing ActiveYeekeReturnItemID), from either matched or confirmed state, +// Cancel releases the active SYB product and return item pointers; actual +// purchase readiness and return-pool eligibility are then re-evaluated. +// Without a precondition it accepts either matched or confirmed state, // 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 err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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 { return errStateConflict } + if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) { + return errStateConflict + } now := s.Now() match.Status = models.ReturnMatchStatusCancelled 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 { 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 } diff --git a/web/src/api/goauto/return-matches.js b/web/src/api/goauto/return-matches.js index 792b9dc..70dcd1d 100644 --- a/web/src/api/goauto/return-matches.js +++ b/web/src/api/goauto/return-matches.js @@ -16,8 +16,8 @@ export function confirmReturnMatch(matchId) { return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' }) } -export function cancelReturnMatch(matchId) { - return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' }) +export function cancelReturnMatch(matchId, data, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post', data, ...options }) } export function remarkReturnMatch(matchId, data) { diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 2b18c5e..f9e8edb 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -13,7 +13,7 @@ - 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}取消匹配{{ returnCancelCandidates.length }}
@@ -72,7 +72,7 @@ 未匹配 @@ -190,6 +190,25 @@ + +
+ 已处理 {{ returnCancelResult.processed }} / {{ returnCancelResult.total }} 条 + 成功 {{ returnCancelResult.successCount }} 条 + 跳过 {{ returnCancelResult.skippedCount }} 条 + 失败 {{ returnCancelResult.failedCount }} 条 +
+ + +

仅取消原退货匹配,不修改已有采集或采购任务。失败项仅在当前页仍可取消时保留勾选;不在当前页的失败项仍在下表中。

+ + + + + + + +
+
成功匹配 {{ returnMatchBatchResult.matchedCount }} 条 @@ -214,7 +233,7 @@

SYB 商品

- + {{ matchDetail.data.syb.orderCode }} {{ matchDetail.data.syb.shopeeItemId }} {{ matchDetail.data.syb.shopName }} @@ -230,7 +249,7 @@

yeeke 退货商品

- + {{ matchDetail.data.yeeke.orderSn }} {{ matchDetail.data.yeeke.itemId }} {{ matchDetail.data.yeeke.variationId }} @@ -260,8 +279,8 @@
@@ -324,6 +343,7 @@ import { ElMessage } from 'element-plus' import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue' import quickLink from './quick-link' +import returnCancel from './return-cancel' import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products' import { listSybShops } from '@/api/goauto/syb-shops' import { listDevices } from '@/api/goauto/devices' @@ -348,7 +368,7 @@ const RETURN_MATCH_BATCH_MAX = 500 export default { name: 'GoAutoSybProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, - mixins: [quickLink], + mixins: [quickLink, returnCancel], setup() { return { Search, RefreshLeft, ArrowDown } }, data() { return { @@ -382,7 +402,7 @@ export default { canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') }, processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] }, returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) }, - aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) }, + aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) }, aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX }, aiMatchButtonReason() { if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' @@ -401,7 +421,7 @@ export default { // #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。 collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX }, collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' }, - imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) }, + imageSearchRows() { return this.selectedProducts.filter(row => this.isImageSearchCandidate(row)) }, // 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks), // 这里用相同口径预估,避免提交后才发现超限。 imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS }, @@ -428,7 +448,7 @@ export default { }, created() { this.load() }, methods: { - async load(allowNetworkRetry = true) { + async load(allowNetworkRetry = true, waitForReadiness = false) { const orderCodes = this.normalizedOrderCodes() if (orderCodes === null) return const generation = ++this.loadGeneration @@ -443,8 +463,11 @@ export default { this.products = r.data.items this.total = r.data.total this.loading = false - void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) - void this.loadReturnMatches(this.products.map(item => item.id), generation) + const pending = Promise.all([ + this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation), + this.loadReturnMatches(this.products.map(item => item.id), generation) + ]) + if (waitForReadiness) await pending } catch (error) { if (generation !== this.loadGeneration) return if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { @@ -482,13 +505,13 @@ export default { return values }, handleSelectionChange(rows) { this.selectedProducts = rows }, - isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' }, - isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, - isAIMatchCandidate(row) { return this.purchaseReady(row).aiMatchEligible === true }, + isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.eligible === true && ready.processStage === 'purchase_ready' }, + isCollectionCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, + isAIMatchCandidate(row) { return !this.isActivelyReturnMatched(row) && this.purchaseReady(row).aiMatchEligible === true }, // 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件 // 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品 // 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。 - isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) }, + isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) }, // #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。 isReturnMatchCandidate(row) { const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready']) @@ -497,7 +520,7 @@ export default { isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' }, // `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates // / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。 - isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) }, + isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) }, rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) }, purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } }, purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, @@ -527,6 +550,7 @@ export default { } this.purchaseReadiness = merged await this.$nextTick() + if (generation !== this.loadGeneration) return const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row)) this.$refs.productTable?.clearSelection() valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) @@ -562,7 +586,7 @@ export default { async runBatchSpecMatch() { if (!this.aiMatchCandidates.length || this.specMatchLoading) return - const selectedIDs = this.selectedProducts.map(row => row.id) + const selectedIDs = this.selectedProducts.filter(row => !this.isActivelyReturnMatched(row)).map(row => row.id) this.specMatchLoading = true try { const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs }) @@ -585,8 +609,10 @@ export default { isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() }, matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status }, async loadReturnMatches(ids, generation = this.loadGeneration) { + if (generation !== this.loadGeneration) return this.returnMatchByProductId = {} - if (!ids.length) return + this.returnMatchLoadError = false + if (!ids.length) { this.returnMatchLoading = false; return } this.returnMatchLoading = true try { const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true }) @@ -598,7 +624,7 @@ export default { } this.returnMatchByProductId = map } catch { - // 展示失败不影响主列表;用户可重新查询重试。 + if (generation === this.loadGeneration) this.returnMatchLoadError = true } finally { if (generation === this.loadGeneration) this.returnMatchLoading = false } @@ -612,7 +638,7 @@ export default { }, async runBatchMatchReturns() { const ids = this.returnMatchCandidateIds - if (!ids.length || this.returnMatchBatchLoading) return + if (!ids.length || this.returnMatchBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return this.returnMatchBatchLoading = true try { const r = await batchMatchReturns({ sybProductIds: ids }) @@ -647,6 +673,7 @@ export default { await this.load() }, async saveMatchRemark() { + if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return this.matchDetail.saving = true try { await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText }) @@ -657,29 +684,35 @@ export default { } }, async confirmMatchDetail() { - try { - await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) - } catch { return } - await confirmReturnMatch(this.matchDetail.id) - ElMessage.success('已确认匹配') - await this.refreshMatchDetail() + return this.runReturnTransition(async() => { + try { + await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) + } catch { return } + await confirmReturnMatch(this.matchDetail.id) + ElMessage.success('已确认匹配') + await this.refreshMatchDetail() + }) }, async cancelMatchDetail() { - try { - await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) - } catch { return } - await cancelReturnMatch(this.matchDetail.id) - ElMessage.success('已取消匹配') - this.matchDetail.open = false - await this.load() + return this.runReturnTransition(async() => { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(this.matchDetail.id) + ElMessage.success('已取消匹配') + this.matchDetail.open = false + await this.load() + }) }, async quickCancelMatch(matchId) { - try { - await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) - } catch { return } - await cancelReturnMatch(matchId) - ElMessage.success('已取消匹配') - await this.load() + return this.runReturnTransition(async() => { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(matchId) + ElMessage.success('已取消匹配') + await this.load() + }) }, // ---------------- 批量创建采购任务 ---------------- 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..83635a6 --- /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.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 } + } + } +} 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/unit/syb-return-cancel.test.cjs b/web/tests/unit/syb-return-cancel.test.cjs new file mode 100644 index 0000000..b32f49f --- /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', '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) +}) From 4280115e11ae26366a27e88818e73f34254ceb35 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 29 Sep 2026 10:21:28 +0800 Subject: [PATCH 2/3] feat: batch use pending return matches #349 --- .../goauto/returnmatch/confirm_batch_test.go | 103 ++++++++++++++++ web/src/api/goauto/return-matches.js | 4 +- web/src/views/goauto/syb-products/index.vue | 36 ++++-- .../goauto/syb-products/return-cancel.js | 4 +- .../goauto/syb-products/return-confirm.js | 102 ++++++++++++++++ web/tests/e2e/syb-return-confirm.spec.ts | 114 ++++++++++++++++++ web/tests/unit/syb-return-cancel.test.cjs | 2 +- web/tests/unit/syb-return-confirm.test.cjs | 112 +++++++++++++++++ 8 files changed, 464 insertions(+), 13 deletions(-) create mode 100644 server/app/goauto/returnmatch/confirm_batch_test.go create mode 100644 web/src/views/goauto/syb-products/return-confirm.js create mode 100644 web/tests/e2e/syb-return-confirm.spec.ts create mode 100644 web/tests/unit/syb-return-confirm.test.cjs diff --git a/server/app/goauto/returnmatch/confirm_batch_test.go b/server/app/goauto/returnmatch/confirm_batch_test.go new file mode 100644 index 0000000..9c645e9 --- /dev/null +++ b/server/app/goauto/returnmatch/confirm_batch_test.go @@ -0,0 +1,103 @@ +package returnmatch + +import ( + "context" + "errors" + "fmt" + "net/http/httptest" + "testing" + + "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" +) + +// #349 reuses the existing single-record transaction; no production batch API. +func TestConfirmBatchExistingHTTPContract(t *testing.T) { + for _, tc := range []struct { + name, status, role string + code int + }{ + {"admin", "matched", "admin", 200}, + {"purchaser", "matched", "purchaser", 200}, + {"after_sales", "matched", "after_sales", 200}, + {"already_confirmed", "confirmed", "after_sales", 409}, + {"cancelled", "cancelled", "purchaser", 409}, + {"forbidden", "matched", "viewer", 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/confirm", m.ID), nil) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}} + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"}) + Handler{DB: db}.Confirm(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 != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 { + t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs) + } + } else if saved.Status != tc.status || logs != 0 { + t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs) + } + }) + } +} + +func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) { + db := testDB(t) + s := NewService(db) + if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound { + t.Fatalf("missing=%v", err) + } + m := seedCancelMatch(t, db, "matched") + if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil { + t.Fatal(err) + } + // Simulate a later rematch with the same product/return, but a new identity. + replacement := m + replacement.ID = 0 + replacement.CancelledAt = nil + replacement.CancelledBy = "" + if err := db.Create(&replacement).Error; err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict { + t.Fatalf("stale original=%v", err) + } + if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict { + t.Fatalf("duplicate=%v", err) + } +} + +func TestConfirmBatchAuditFailureRollsBack(t *testing.T) { + db := testDB(t) + m := seedCancelMatch(t, db, "matched") + if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_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).Confirm(context.Background(), m.ID, "tester"); err == nil { + t.Fatal("expected audit failure") + } + var saved models.ReturnMatch + db.First(&saved, m.ID) + if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil { + t.Fatalf("confirm did not roll back: %+v", saved) + } +} diff --git a/web/src/api/goauto/return-matches.js b/web/src/api/goauto/return-matches.js index 70dcd1d..074ccf1 100644 --- a/web/src/api/goauto/return-matches.js +++ b/web/src/api/goauto/return-matches.js @@ -12,8 +12,8 @@ export function batchMatchReturns(data) { return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data }) } -export function confirmReturnMatch(matchId) { - return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' }) +export function confirmReturnMatch(matchId, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post', ...options }) } export function cancelReturnMatch(matchId, data, options = {}) { diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index f9e8edb..d0e1517 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -13,7 +13,7 @@ - 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}取消匹配{{ returnCancelCandidates.length }} + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}取消匹配{{ returnCancelCandidates.length }}使用退货{{ returnConfirmCandidates.length }}
@@ -72,7 +72,7 @@ 未匹配 @@ -209,6 +209,25 @@ + +
+ 已处理 {{ returnConfirmResult.processed }} / {{ returnConfirmResult.total }} 条 + 成功 {{ returnConfirmResult.successCount }} 条 + 跳过 {{ returnConfirmResult.skippedCount }} 条 + 失败 {{ returnConfirmResult.failedCount }} 条 +
+ + +

仅标记已用退货,不修改已有采购任务,也不会自动让 Yeeke 发货。失败项仅在当前页仍为原待确认匹配时保留勾选;离页失败项仍保留在下表。

+ + + + + + + +
+
成功匹配 {{ returnMatchBatchResult.matchedCount }} 条 @@ -266,7 +285,7 @@

备注

- 保存备注 + 保存备注

操作日志

@@ -279,8 +298,8 @@
@@ -344,6 +363,7 @@ import { ElMessage } from 'element-plus' import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue' import quickLink from './quick-link' import returnCancel from './return-cancel' +import 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' @@ -368,7 +388,7 @@ const RETURN_MATCH_BATCH_MAX = 500 export default { name: 'GoAutoSybProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, - mixins: [quickLink, returnCancel], + mixins: [quickLink, returnCancel, returnConfirm], setup() { return { Search, RefreshLeft, ArrowDown } }, data() { return { @@ -638,7 +658,7 @@ export default { }, async runBatchMatchReturns() { const ids = this.returnMatchCandidateIds - if (!ids.length || this.returnMatchBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) 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 }) @@ -673,7 +693,7 @@ export default { await this.load() }, async saveMatchRemark() { - if (this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return + 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 }) diff --git a/web/src/views/goauto/syb-products/return-cancel.js b/web/src/views/goauto/syb-products/return-cancel.js index 83635a6..c894718 100644 --- a/web/src/views/goauto/syb-products/return-cancel.js +++ b/web/src/views/goauto/syb-products/return-cancel.js @@ -18,7 +18,7 @@ export default { 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.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候' if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消' return '' } @@ -30,7 +30,7 @@ export default { 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 + if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return this.returnTransitionLoading = true try { await action() } finally { this.returnTransitionLoading = 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/tests/e2e/syb-return-confirm.spec.ts b/web/tests/e2e/syb-return-confirm.spec.ts new file mode 100644 index 0000000..7267d73 --- /dev/null +++ b/web/tests/e2e/syb-return-confirm.spec.ts @@ -0,0 +1,114 @@ +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.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() + 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.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 index b32f49f..54dc9b5 100644 --- a/web/tests/unit/syb-return-cancel.test.cjs +++ b/web/tests/unit/syb-return-cancel.test.cjs @@ -100,7 +100,7 @@ test('legacy interceptor false/6401 result is auth failure, not cancellation suc 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', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}) + 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 } 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) +}) From 14231c62a75789613fbada2c309f0b147f9a2618 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 29 Sep 2026 10:24:54 +0800 Subject: [PATCH 3/3] docs: record batch return use contract and verification #349 --- docs/02-architecture-and-code-map.md | 13 +++++++++++-- docs/03-business-rules-and-glossary.md | 14 ++++++++++++-- docs/08-agent-api-contract.md | 13 +++++++++++-- web/tests/e2e/syb-return-confirm.spec.ts | 3 +++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index d76ae51..6289c66 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 0e0ad0b3312c524d90e063167dd2e945fb22b0e9 -synchronized_at: 2026-09-29T01:36:01Z +wiki_revision: 9b4a1a06b8e2f6a8dfdbaef87817d840dfa45389 +synchronized_at: 2026-09-29T02:23:54Z @@ -561,3 +561,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。 - `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。 - 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。 + +## SYB 当前页批量使用退货(#349) + +实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。 + +- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。 +- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。 +- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。 +- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 4aa9fb6..c1c0505 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: e95c53cec27080e073e6f7a4aaed46d5421dddf5 -synchronized_at: 2026-09-29T01:36:04Z +wiki_revision: 9a7317728132c450c7abf244c3e5eaac7bfb71bc +synchronized_at: 2026-09-29T02:23:57Z @@ -722,3 +722,13 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 - 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。 - 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。 - 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。 + +## SYB 当前页批量使用退货(#349) + +实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。 + +- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。 +- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。 +- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。 +- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。 +- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ec3818a..c10bab3 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: fff074b921368be646cda1bf0a434608fc5082c8 -synchronized_at: 2026-09-29T01:36:20Z +wiki_revision: 102d40fb2d960b05684e2a82e85729362070fdb5 +synchronized_at: 2026-09-29T02:24:16Z @@ -1408,3 +1408,12 @@ GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 sy 携带前提时,服务端在锁定 `: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 重新查询替代匹配并取消。 + +## Admin 批量使用退货复用确认接口(#349) + +实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。 + +- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。 +- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。 +- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。 +- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。 diff --git a/web/tests/e2e/syb-return-confirm.spec.ts b/web/tests/e2e/syb-return-confirm.spec.ts index 7267d73..1d0d9ea 100644 --- a/web/tests/e2e/syb-return-confirm.spec.ts +++ b/web/tests/e2e/syb-return-confirm.spec.ts @@ -64,6 +64,7 @@ test('v2 button order, one confirmation, no write on return, progress and mutual 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() }) @@ -78,6 +79,7 @@ for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) { 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() @@ -110,5 +112,6 @@ test('pending filter and page size retained; used rows disappear', async ({ page 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() })