diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index a114647..583f595 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: ad4a5c3c7e4c2e3631856b3021e11560be2e702e -synchronized_at: 2026-09-27T03:42:29Z +wiki_revision: 04cc54a554fc3bdc30273e6a5ace5fff5d95386f +synchronized_at: 2026-09-28T08:28:30Z @@ -504,7 +504,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 ### 会话类失败自动重试(#330) -- purchase/order_writeback_worker.go:回填通过 `sybclient.AcquireSession` 统一复用/刷新 SYB 会话;`syb_session_auth_lease` 保证同步与回填同时只有一个 worker 触发 OCR 登录,其他任务等待新会话。回填终态同步 `purchase_task.writeback_status/writeback_at`;迁移为 `1789801400000_syb_session_auth_lease.go`。会话类失败仍使用有界退避,冲突不覆盖。 +- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。 - purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。 - 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。 @@ -547,3 +547,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 ## #338 退货匹配(2026-09-27) 新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。 + + +## Yeeke 已重出同步与匹配边界(#345) + +- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。 +- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。 +- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。 +- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。 +- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index c37f812..5a3b637 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: 57af37bf6acf16f86785c989e5c37dce85a3c588 -synchronized_at: 2026-09-27T03:42:33Z +wiki_revision: 57ae18796e441eeed7b0ffc2a833fe6b4b5055f1 +synchronized_at: 2026-09-28T08:28:33Z @@ -648,7 +648,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。 ### SYB 会话类失败的有界自动重试(#330) -实现 934a7be(#343,2026-09-27)。回填 worker 与 SYB 同步共用 `sybclient.AcquireSession`:有效缓存会话直接复用,明确失效时由 `syb_session_auth_lease` 保证单 worker OCR 登录,其他任务等待新会话后继续原回填。网络错误不清除有效会话;OCR 未配置或登录失败保留结构化失败并允许人工重试。回填创建时采购任务进入 `pending`,终态同步 `succeeded/failed` 与 `writeback_at`;冲突仍拒绝覆盖。会话类失败继续使用有界退避,成功须回读确认。 +实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。 ## Agent 回填订单入口兼容(#307) @@ -707,3 +707,12 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 ## 退货匹配(#338) 退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。 + + +## Yeeke 双状态退货同步(#345) + +- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。 +- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。 +- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。 +- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。 +- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。 diff --git a/server/app/goauto/returnmatch/reship_test.go b/server/app/goauto/returnmatch/reship_test.go new file mode 100644 index 0000000..561cb65 --- /dev/null +++ b/server/app/goauto/returnmatch/reship_test.go @@ -0,0 +1,38 @@ +package returnmatch + +import ( + "context" + "errors" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) { + db := testDB(t) + s := NewService(db) + deadline := time.Now().Add(24 * time.Hour) + ret := seedReturn(t, db, "红色", &deadline) + sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now()) + pool, err := s.availableReturnPool(context.Background()) + if err != nil || len(pool) != 1 { + t.Fatalf("waiting pool=%v err=%v", pool, err) + } + if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil { + t.Fatal(err) + } + pool, err = s.availableReturnPool(context.Background()) + if err != nil || len(pool) != 0 { + t.Fatalf("reshipped pool=%v err=%v", pool, err) + } + _, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test") + if !errors.Is(err, errReturnNoLongerEligible) { + t.Fatalf("final recheck=%v", err) + } + var count int64 + db.Model(&models.ReturnMatch{}).Count(&count) + if count != 0 { + t.Fatal("reshipped item was allocated") + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 2b3f26c..8709d20 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -238,6 +238,11 @@ func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchM } match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator) if insertErr != nil { + if errors.Is(insertErr, errReturnNoLongerEligible) { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"}) + resp.SkippedCount++ + continue + } if errors.Is(insertErr, errStageNoLongerEligible) { // #338 review fix: the stage was re-checked under the same // FOR UPDATE lock purchase.create takes, right before insert. @@ -292,6 +297,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id"). Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok"). + Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false). Find(&rows).Error if err != nil { return nil, err @@ -312,6 +318,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e // it between BatchMatch's outer screening pass and this point (#338 review // fix: race between matching and purchase creation). var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching") +var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship") // matchOneWithLock takes the SAME row lock purchase.Service.create takes on // syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the @@ -340,6 +347,17 @@ func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome Ma if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil { return err } + var pkg models.YeekeReturnPackage + if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil { + return err + } + // Same package -> item lock order as sync upsert; avoid a lock cycle. + if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil { + return err + } + if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" { + return errReturnNoLongerEligible + } sybIDCopy := syb.ID returnIDCopy := outcome.ReturnItemID deadline := outcome.DestroyDeadline diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go index 0de5464..95e02b8 100644 --- a/server/app/goauto/returnmatch/service_test.go +++ b/server/app/goauto/returnmatch/service_test.go @@ -69,7 +69,8 @@ func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem { t.Helper() pkg := models.YeekeReturnPackage{ - ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", + ClaimStatus: "1", + ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", DestroyDeadLine: deadline, LastSyncedAt: time.Now(), } if err := db.Create(&pkg).Error; err != nil { diff --git a/server/app/goauto/yeeke/reship_test.go b/server/app/goauto/yeeke/reship_test.go new file mode 100644 index 0000000..f071228 --- /dev/null +++ b/server/app/goauto/yeeke/reship_test.go @@ -0,0 +1,112 @@ +package yeeke + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" +) + +func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) { + db := testDB(t) + phase := 0 + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Status string `json:"status"` + Page int `json:"pageNo"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + return + } + calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page)) + w.Header().Set("Content-Type", "application/json") + if phase == 0 && req.Status == "2" { + fmt.Fprint(w, page(nil, 0, 0)) + return + } + if req.Status == "1" { + if req.Page == 1 { + fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2)) + } else { + fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2)) + } + } else { + fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1)) + } + })) + defer srv.Close() + c, _ := yeekeclient.New(srv.URL) + s := NewService(db, c, Config{PageSize: 1}) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatal(err) + } + var original models.YeekeReturnPackage + db.Where("external_id = ?", "p1").First(&original) + phase = 1 + calls = nil + for run := 0; run < 2; run++ { + rep, err := s.Sync(context.Background(), "manual") + if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v", rep, err) + } + } + if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" { + t.Fatalf("independent pagination: %v", calls) + } + var current models.YeekeReturnPackage + db.First(¤t, original.ID) + if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" { + t.Fatalf("current=%+v", current) + } + var count int64 + db.Model(&models.YeekeReturnPackage{}).Count(&count) + if count != 2 { + t.Fatalf("packages=%d", count) + } + db.Model(&models.YeekeReturnItem{}).Count(&count) + if count != 2 { + t.Fatalf("items=%d", count) + } +} + +func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) { + db := testDB(t) + old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"} + if err := db.Create(&old).Error; err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Status string `json:"status"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.Status == "2" { + w.WriteHeader(http.StatusBadGateway) + return + } + fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1)) + })) + defer srv.Close() + c, _ := yeekeclient.New(srv.URL) + s := NewService(db, c, Config{PageSize: 10}) + rep, err := s.Sync(context.Background(), "manual") + if err == nil || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v", rep, err) + } + db.First(&old, old.ID) + if old.SyncStatus != "ok" { + t.Fatal("incomplete combined sync marked reshipped package missing") + } + var run models.YeekeSyncRun + db.First(&run, rep.RunID) + if run.ErrorMessage == "" { + t.Fatal("missing state/page failure diagnostic") + } +} diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index ef68630..519da7b 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -54,10 +54,8 @@ type Report struct { } // knownClaimStatuses lists the status values the sync code currently -// understands. The list surface (POST .../relation/list) is queried with -// status=1, so "1" is the only value observed in practice; anything else is -// flagged rather than silently accepted or rejected (#336). -var knownClaimStatuses = map[string]bool{"1": true} +// understands: waiting to ship (1) and reshipped (2), confirmed by HAR. +var knownClaimStatuses = map[string]bool{"1": true, "2": true} func external(v any) string { return fmt.Sprint(v) } func stamp(t *yeekeclient.Timestamp) *time.Time { @@ -194,86 +192,103 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } s.db.Model(r).Updates(updates) }() - seen := map[string]bool{} var firstWriteErr error + seenPackages := map[string]string{} // complete tracks whether the page walk ended NATURALLY (empty page, // short page, or reaching p.Pages) as opposed to the duplicate- // fingerprint break or MaxPages exhaustion (#338): only a naturally // complete run is trusted to mark absent items/packages "missing" below, // since a duplicate/MaxPages stop means the walk never actually finished // seeing everything yeeke currently has. - complete := false - for page := 1; page <= s.cfg.MaxPages; page++ { - var p yeekeclient.ReturnPage - var e error - for a := 0; ; a++ { - p, e = s.client.List(ctx, page, s.cfg.PageSize) - if e == nil || a >= s.cfg.Retry { + complete := true + for _, status := range []string{"1", "2"} { + seen := map[string]bool{} + statusComplete := false + for page := 1; page <= s.cfg.MaxPages; page++ { + var p yeekeclient.ReturnPage + var e error + for a := 0; ; a++ { + p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status) + if e == nil || a >= s.cfg.Retry { + break + } + select { + case <-ctx.Done(): + runErr = ctx.Err() + errMsg = truncateRunError(runErr.Error()) + return rep, runErr + case <-time.After(time.Duration(a+1) * 100 * time.Millisecond): + } + } + if e != nil { + // A failed page never overwrites what earlier pages already wrote + // (#336): the run simply stops here and everything upserted so far + // stays as-is, reported through Read/Created/Updated above. + runErr = e + errMsg = truncateRunError(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e)) + return rep, runErr + } + rep.TotalPages++ + if len(p.Records) == 0 { + statusComplete = true break } - select { - case <-ctx.Done(): - runErr = ctx.Err() - errMsg = truncateRunError(runErr.Error()) - return rep, runErr - case <-time.After(time.Duration(a+1) * 100 * time.Millisecond): + finger := pageFingerprint(p) + if seen[finger] { + rep.Skipped += len(p.Records) + break } - } - if e != nil { - // A failed page never overwrites what earlier pages already wrote - // (#336): the run simply stops here and everything upserted so far - // stays as-is, reported through Read/Created/Updated above. - runErr = e - errMsg = truncateRunError(e.Error()) - return rep, runErr - } - rep.TotalPages = page - if len(p.Records) == 0 { - complete = true - break - } - finger := pageFingerprint(p) - if seen[finger] { - rep.Skipped += len(p.Records) - break - } - seen[finger] = true - for _, x := range p.Records { - created, updated, recovered, err := s.upsert(ctx, x) - if err != nil { - rep.Failed++ - if firstWriteErr == nil { - firstWriteErr = err + seen[finger] = true + for _, x := range p.Records { + key, currentStatus := packageKey(x), external(x.Status) + if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) { + rep.Skipped++ + continue + } + created, updated, recovered, err := s.upsert(ctx, x) + seenPackages[key] = currentStatus + if err != nil { + rep.Failed++ + if firstWriteErr == nil { + firstWriteErr = err + } + continue + } + rep.Read++ + rep.Recovered += recovered + if created { + rep.Created++ + } else if updated { + rep.Updated++ + } else { + rep.Skipped++ } - continue } - rep.Read++ - rep.Recovered += recovered - if created { - rep.Created++ - } else if updated { - rep.Updated++ - } else { - rep.Skipped++ + if len(p.Records) < s.cfg.PageSize { + statusComplete = true + break + } + if p.Pages > 0 && page >= p.Pages { + statusComplete = true + break } } - if len(p.Records) < s.cfg.PageSize { - complete = true - break - } - if p.Pages > 0 && page >= p.Pages { - complete = true + if !statusComplete { + complete = false + errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status) break } } + if !complete { + runErr = errors.New(errMsg) + return rep, runErr + } rep.Status = "succeeded" if rep.Failed > 0 && firstWriteErr != nil { // Surface why records failed instead of a bare counter. errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr)) - if rep.Read == 0 { - rep.Status = "failed" - runErr = errors.New(errMsg) - } + rep.Status = "failed" + runErr = errors.New(errMsg) } // #338: only a naturally complete run with zero write failures is // trusted to mark items/packages the sync no longer sees as "missing". @@ -288,7 +303,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro rep.MissingMarked = marked } } - return rep, nil + return rep, runErr } // markMissing implements #338's completion-triggered availability flip: any diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go index 0e80fc8..a842bd6 100644 --- a/server/app/goauto/yeeke/sync_missing_test.go +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -204,11 +204,8 @@ func TestDuplicateFingerprintStopsMarking(t *testing.T) { defer srv2.Close() s.client, _ = yeekeclient.New(srv2.URL) rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatalf("second sync: %v", err) - } - if rep.MissingMarked != 0 { - t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked) + if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err) } var p2 models.YeekeReturnPackage @@ -250,11 +247,8 @@ func TestMaxPagesExhaustionStopsMarking(t *testing.T) { s.client, _ = yeekeclient.New(srv2.URL) s.cfg.MaxPages = 2 rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatalf("second sync: %v", err) - } - if rep.MissingMarked != 0 { - t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked) + if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 { + t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err) } var p2 models.YeekeReturnPackage diff --git a/server/app/goauto/yeeke/sync_paging_test.go b/server/app/goauto/yeeke/sync_paging_test.go index a9a502d..3c494d8 100644 --- a/server/app/goauto/yeeke/sync_paging_test.go +++ b/server/app/goauto/yeeke/sync_paging_test.go @@ -108,10 +108,7 @@ func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) { c, _ := yeekeclient.New(srv.URL) s := NewService(db, c, Config{PageSize: 1}) rep, err := s.Sync(context.Background(), "manual") - if err != nil { - t.Fatal(err) - } - if rep.Status != "succeeded" { + if err == nil || rep.Status != "failed" { t.Fatalf("rep=%+v", rep) } var n int64 @@ -138,7 +135,7 @@ func TestPagingStopsOnEmptyPage(t *testing.T) { if err != nil { t.Fatal(err) } - if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 { + if rep.Status != "succeeded" || rep.TotalPages != 2 || rep.Read != 0 { t.Fatalf("rep=%+v", rep) } } diff --git a/server/app/goauto/yeekeclient/client.go b/server/app/goauto/yeekeclient/client.go index 3a64fca..a462798 100644 --- a/server/app/goauto/yeekeclient/client.go +++ b/server/app/goauto/yeekeclient/client.go @@ -371,8 +371,16 @@ func (f *FlexInt) UnmarshalJSON(b []byte) error { } func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) { + return c.ListStatus(ctx, pageNo, pageSize, "1") +} + +// ListStatus reads only the two HAR-confirmed return statuses. +func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) { + if status != "1" && status != "2" { + return ReturnPage{}, fmt.Errorf("unsupported yeeke return status") + } // Same shape the web client posts (HAR): sort via column/order, filters as strings. - body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"} + body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": status, "relationFlag": "1", "column": "createTime", "order": "desc"} raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil) if e != nil { return ReturnPage{}, e diff --git a/server/app/goauto/yeekeclient/reship_test.go b/server/app/goauto/yeekeclient/reship_test.go new file mode 100644 index 0000000..3fca99e --- /dev/null +++ b/server/app/goauto/yeekeclient/reship_test.go @@ -0,0 +1,36 @@ +package yeekeclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListStatusUsesConfirmedHARFilters(t *testing.T) { + var statuses []string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" { + t.Error("HAR filters changed") + } + statuses = append(statuses, body["status"].(string)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`)) + })) + defer s.Close() + c, _ := New(s.URL) + for _, status := range []string{"1", "2"} { + if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil { + t.Fatal(err) + } + } + if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil { + t.Fatal("unsupported status allowed") + } + if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" { + t.Fatal(statuses) + } +} diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index 714a9c9..427e592 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -172,24 +172,17 @@ export default { { label: '退货待确认', value: 'matched' }, { label: '已用退货', value: 'confirmed' } ], - // yeeke's only confirmed claim-status value is the raw string "1" - // (HAR evidence, #336 requirement doc). Any other value the sync sees - // is preserved verbatim and flagged status_unrecognized — never - // guessed into a label here. "unknown" is a server-side pseudo-filter - // matching status_unrecognized=true, not a real yeeke value. + // HAR-confirmed business statuses; unknown remains a server-side filter. claimStatusOptions: [ - { label: '已认领', value: '1' }, + { label: '待出货', value: '1' }, + { label: '已重出', value: '2' }, { label: '未知(原样展示)', value: 'unknown' } ] } }, created() { this.load(); this.loadSummary(); this.loadShops() }, methods: { - // "1" is yeeke's only confirmed known status; anything else reaching - // this function (status_unrecognized=false from the server) is an - // unmapped value we have not seen yet — show it as-is rather than a - // bare digit or a guessed Chinese label. - claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } }, + claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } }, matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } }, openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) }, statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } }, diff --git a/web/tests/unit/yeeke-status.test.cjs b/web/tests/unit/yeeke-status.test.cjs new file mode 100644 index 0000000..428b0cc --- /dev/null +++ b/web/tests/unit/yeeke-status.test.cjs @@ -0,0 +1,27 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8') +const script = file.match(/