Compare commits

...
3 Commits
Author SHA1 Message Date
QiuSWandClaude Opus 5.5 f2c8307c17 feat(web): return-matching UI on SYB products and yeeke returns pages (#338)
SYB 订单商品页 (web/src/views/goauto/syb-products/index.vue):
- 「匹配退货」按钮,作用于勾选行中处于参与匹配阶段的商品(与服务端
  returnmatch.participatingStages 同一口径),canPurchase 门禁,忙碌时
  loading,无候选时禁用。
- 批量结果对话框:成功匹配/无候选/阶段不参与跳过/并发冲突跳过四类计数
  + 逐行原因表格。
- 处理阶段筛选与展示新增「退货待确认」「已用退货」两个选项/标签(复用
  sybimport 现有的、基于 purchase.ProcessStages 的阶段筛选机制,无需
  额外服务端改动——process_stage.go 上一次提交已让这两个阶段参与其中)。
- 新增「匹配到的退货商品」列(缩略图/退货订单号/规格/销毁截止),逐行
  「查看对比」「备注」「取消匹配」(后两者仅 canPurchase 可见)。
- 已有效匹配的行不可勾选(isReturnMatchCandidate 排除 return_pending/
  return_used 两个阶段)。
- 匹配详情对话框:SYB/yeeke 双方字段与归一化规格文字并排对比、备注
  编辑、操作日志列表、确认(仅 matched 状态可用)/取消匹配(matched
  或 confirmed 可用)按钮,取消前均有二次确认。

退货商品页 (web/src/views/goauto/yeeke-returns/index.vue):
- 新增「匹配状态」筛选(全部/未匹配/退货待确认/已用退货),对所有用户
  可见(纯只读筛选,不需要 canPurchase)。
- 新增「被占用 SYB 订单商品」列(订单号 + 当前阶段,点击跳转 SYB 商品
  页)。

New file web/src/api/goauto/return-matches.js: listReturnMatches/
getReturnMatch/batchMatchReturns/confirmReturnMatch/cancelReturnMatch/
remarkReturnMatch.

Backend support for the match column: returnmatch.Service.List now
returns ListItem (match + orderSn/image/variationName/destroyDeadline)
via one bounded join over the page's yeeke_return_item ids, so the SYB
product page's match column never needs a second request per row
(service.go, service_test.go: TestList_EnrichesWithYeekeReturnDisplayFields).

采购创建拦截的 UI 呈现沿用现有机制:ProcessStageReturnPending/
ProcessStageReturnUsed 让 isPurchaseCandidate(row) 天然为 false(与其余
非 purchase_ready 阶段的既有行为一致),「采购」按钮不显示,处理阶段列
展示服务端返回的拦截原因文案;未额外改动采购创建流程/UI。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 12:03:10 +08:00
QiuSWandClaude Opus 5.5 92c42ba1fe feat(yeeke): add match-status filter and occupying-SYB column to returns list (#338)
ListReturnPackages (app/goauto/yeeke/list.go) gains a single bounded
LEFT JOIN onto return_match (active match only) and syb_product, plus
a matchStatus query param (unmatched/matched/confirmed — invalid
values are rejected, not ignored). ReturnItemRowDTO now carries
matchId/matchStatus/occupyingSybProductId/occupyingSybOrderCode/
occupyingSybStage(+Label). The occupying SYB product's current stage
label is filled from one extra bounded purchase.NewService(db).
ProcessStages call over the page's distinct occupying SYB ids — not a
per-row lookup — so the yeeke returns page's new 匹配状态 filter and
被占用 SYB 订单商品 column need no N+1 query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:58:24 +08:00
QiuSWandClaude Opus 5.5 fc99be0392 fix(returnmatch): dedicated log table, race-safe matching, spec/remark fixes (#338)
Review fixes on the #338 backend:

1. New table return_match_log (models/return_match_log.go, registered
   in migrations.MigratedModels()): match_id/action/operator/detail/
   created_at. sys_opera_log is a generic per-HTTP-call framework log,
   not queryable per match and not carrying operator/detail in a
   stable shape, so match/confirm/cancel/remark each write their own
   log row in the SAME transaction as the state change. Detail now
   returns the logs (newest first).

2. Race between matching and purchase creation: BatchMatch's outer
   screening pass (stage check outside any lock) is now followed by
   matchOneWithLock, which takes the same clause.Locking{Strength:
   "UPDATE"} lock on syb_product that purchase.Service.create takes,
   re-computes the stage inside that transaction via
   purchase.NewService(tx).ProcessStages, and returns
   errStageNoLongerEligible (surfaced as reasonCode
   stage_ineligible) if the product is no longer in a participating
   stage instead of inserting a stale match.
   TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock
   covers the skip path.

3. matchOneWithLock now fills YeekeSpecText (raw variation_name) and
   PreviousProcessStage (the stage code at match time) on the
   inserted row. Remark takes an operator (for its log row) and
   rejects input over 500 runes with errRemarkTooLong instead of
   truncating (varchar(500) is a character-count limit in MySQL, so
   the check is utf8.RuneCountInString, not len()).

4. returnmatch.SYBSpecText now joins only non-empty color/size parts,
   so a single-dimension spec (e.g. color-only) no longer produces a
   stray leading/trailing comma ("黑色" instead of "黑色,"); matchKey
   additionally trims leading/trailing commas from both normalized
   sides via the new trimCommas() helper. New tests cover color-only
   and size-only matching through SelectMatches plus SYBSpecText/
   trimCommas directly.

5. Detail (service.go) now returns MatchDetail: SYBDetailView (order
   code, shopee item id, shop, title, target color/size, quantity,
   image, current computed stage+label), YeekeDetailView (return
   order sn, item id, variation id, shop, item name, variation name,
   quantity, image, destroy deadline), both sides' normalized spec
   text, the match row itself, and the operation logs — everything
   the prototype's compare screen (screen 3) needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:56:14 +08:00
14 changed files with 1013 additions and 45 deletions
+1
View File
@@ -80,6 +80,7 @@ func MigratedModels() []any {
&models.PDDProductReplacementItem{},
&models.PDDProductReplacementWorkerLease{},
&models.ReturnMatch{},
&models.ReturnMatchLog{},
}
}
@@ -0,0 +1,28 @@
package models
import "time"
// ReturnMatchLog is #338's dedicated operation log for the return-matching
// feature. The framework's generic sys_opera_log records raw HTTP
// request/response per call and is not queryable per match id nor does it
// carry a stable action/operator/detail shape, so this table is written
// explicitly, in the same transaction as the action it records (match /
// confirm / cancel / remark).
const (
ReturnMatchLogActionMatched = "matched"
ReturnMatchLogActionConfirmed = "confirmed"
ReturnMatchLogActionCancelled = "cancelled"
ReturnMatchLogActionRemark = "remark"
)
type ReturnMatchLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
MatchID uint64 `json:"matchId" gorm:"not null;index"`
Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"`
Operator string `json:"operator" gorm:"size:64;not null;default:''"`
Detail string `json:"detail" gorm:"size:1000;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
}
func (ReturnMatchLog) TableName() string { return "return_match_log" }
+2 -2
View File
@@ -70,7 +70,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.
if !r.DestroyDeadline.After(now) {
continue
}
key := matchKey(r.ItemID, Normalize(r.VariationName))
key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName)))
buckets[key] = append(buckets[key], r)
}
for key := range buckets {
@@ -84,7 +84,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.
used := make(map[uint64]bool)
outcomes := make([]MatchOutcome, 0, len(products))
for _, p := range products {
key := matchKey(p.ShopeeItemID, Normalize(SYBSpecText(p.TargetColor, p.TargetSize)))
key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize))))
var picked *ReturnCandidate
for i := range buckets[key] {
cand := buckets[key][i]
@@ -118,6 +118,26 @@ func TestSelectMatches_QuantityIgnored(t *testing.T) {
}
}
func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("color-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("size-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
+8 -3
View File
@@ -116,7 +116,7 @@ func (h Handler) Detail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
return
}
match, err := NewService(db).Detail(c.Request.Context(), id)
detail, err := NewService(db).Detail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
@@ -125,7 +125,7 @@ func (h Handler) Detail(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail})
}
func (h Handler) Confirm(c *gin.Context) {
@@ -192,12 +192,17 @@ func (h Handler) Remark(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"})
return
}
match, err := NewService(db).Remark(c.Request.Context(), id, body.Remark)
_, operator := operatorFromContext(c)
match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
return
}
if errors.Is(err, errRemarkTooLong) {
c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
+21 -2
View File
@@ -94,7 +94,26 @@ func isSpace(r rune) bool {
}
// SYBSpecText builds the SYB-side comparable spec text from the parser's
// target_color/target_size fields, per issue #338's data design.
// target_color/target_size fields, per issue #338's data design. Only
// non-empty parts are joined so a single-dimension spec (color-only or
// size-only) doesn't pick up a stray leading/trailing comma that would
// prevent it from normalizing equal to the yeeke side's single-value
// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,").
func SYBSpecText(targetColor, targetSize string) string {
return targetColor + "," + targetSize
parts := make([]string, 0, 2)
if targetColor != "" {
parts = append(parts, targetColor)
}
if targetSize != "" {
parts = append(parts, targetSize)
}
return strings.Join(parts, ",")
}
// trimCommas removes leading/trailing commas left over after normalization
// (e.g. a source value that itself started or ended with a comma). Used only
// when building the matching key, so exact-text comparisons/tests elsewhere
// are unaffected.
func trimCommas(s string) string {
return strings.Trim(s, ",")
}
@@ -49,3 +49,30 @@ func TestSYBSpecText(t *testing.T) {
t.Fatalf("got %q", got)
}
}
func TestSYBSpecText_SingleDimension(t *testing.T) {
if got := SYBSpecText("黑色", ""); got != "黑色" {
t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色")
}
if got := SYBSpecText("", "L"); got != "L" {
t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L")
}
if got := SYBSpecText("", ""); got != "" {
t.Fatalf("both empty: got %q, want empty", got)
}
}
func TestTrimCommas(t *testing.T) {
cases := map[string]string{
"黑色,": "黑色",
",黑色": "黑色",
",黑色,": "黑色",
"黑色": "黑色",
"": "",
}
for in, want := range cases {
if got := trimCommas(in); got != want {
t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want)
}
}
}
+245 -28
View File
@@ -3,8 +3,10 @@ package returnmatch
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -132,8 +134,19 @@ func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchM
resp.SkippedCount++
continue
}
match, insertErr := s.insertMatch(ctx, sybByID[id], outcome, req.Operator)
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
if insertErr != nil {
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.
// A purchase task could have been created for this row
// between the outer screening pass above and this point;
// when that happens the product is no longer in a
// participating stage and this row is skipped, not matched.
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"})
resp.SkippedCount++
continue
}
if isUniqueConstraintErr(insertErr) {
// Rule 7 / acceptance item 9: a concurrent insert lost the
// race for either side — skip this row and report it, never
@@ -188,23 +201,66 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
return out, nil
}
func (s *Service) insertMatch(ctx context.Context, syb models.SYBProduct, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
sybID := syb.ID
returnID := outcome.ReturnItemID
now := s.Now()
match := models.ReturnMatch{
SYBProductID: sybID, YeekeReturnItemID: returnID,
ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &returnID,
Status: models.ReturnMatchStatusMatched,
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
NormalizedKey: outcome.NormalizedKey,
MatchedBy: operator, MatchedAt: now,
DestroyDeadlineSnapshot: &outcome.DestroyDeadline,
}
if err := s.DB.WithContext(ctx).Create(&match).Error; err != nil {
return models.ReturnMatch{}, err
}
return match, nil
// errStageNoLongerEligible is returned by matchOneWithLock when the SYB
// product's process stage, re-checked under lock immediately before insert,
// is no longer in participatingStages — e.g. a purchase task was created for
// 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")
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
// process stage inside that transaction via purchase.NewService(tx) before
// inserting the match, so a purchase task creation racing with this batch
// match can never both succeed: whichever gets the row lock first commits,
// and the other sees the now-current state (an active return_match row, or
// a task_created/order_review/purchase_succeeded stage) and is rejected/
// skipped instead of double-committing an inconsistent state.
func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil {
return err
}
stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID})
if err != nil {
return err
}
stage := stages[sybID]
if !participatingStages[stage.Stage] {
return errStageNoLongerEligible
}
var returnItem models.YeekeReturnItem
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
sybIDCopy := syb.ID
returnIDCopy := outcome.ReturnItemID
deadline := outcome.DestroyDeadline
now := s.Now()
match = models.ReturnMatch{
SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy,
ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy,
Status: models.ReturnMatchStatusMatched,
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
YeekeSpecText: returnItem.VariationName,
NormalizedKey: outcome.NormalizedKey,
PreviousProcessStage: stage.Stage,
MatchedBy: operator,
MatchedAt: now,
DestroyDeadlineSnapshot: &deadline,
}
if err := tx.Create(&match).Error; err != nil {
return err
}
logRow := models.ReturnMatchLog{
MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator,
Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label),
}
return tx.Create(&logRow).Error
})
return match, err
}
func isUniqueConstraintErr(err error) bool {
@@ -227,7 +283,10 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string)
match.Status = models.ReturnMatchStatusConfirmed
match.ConfirmedBy = operator
match.ConfirmedAt = &now
return tx.Save(&match).Error
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error
})
return match, err
}
@@ -251,19 +310,35 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
match.ActiveYeekeReturnItemID = nil
match.CancelledBy = operator
match.CancelledAt = &now
return tx.Save(&match).Error
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 match, err
}
func (s *Service) Remark(ctx context.Context, matchID uint64, remark string) (models.ReturnMatch, error) {
// errRemarkTooLong is returned instead of silently truncating; remark is a
// varchar(500) column and MySQL VARCHAR length is a character count, so this
// checks runes, not bytes.
var errRemarkTooLong = errors.New("remark exceeds 500 characters")
const maxRemarkLength = 500
func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) {
if utf8.RuneCountInString(remark) > maxRemarkLength {
return models.ReturnMatch{}, errRemarkTooLong
}
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&match, matchID).Error; err != nil {
return err
}
match.Remark = remark
return tx.Save(&match).Error
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error
})
return match, err
}
@@ -276,7 +351,18 @@ type ListFilter struct {
Status string
}
func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnMatch, error) {
// ListItem is one row of List's result: the match itself plus a few display
// fields the SYB product page's match column needs (thumbnail, return order
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
}
func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) {
q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{})
if len(filter.SYBProductIDs) > 0 {
q = q.Where("syb_product_id IN ?", filter.SYBProductIDs)
@@ -288,14 +374,145 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnM
q = q.Where("status = ?", filter.Status)
}
var rows []models.ReturnMatch
err := q.Order("id DESC").Find(&rows).Error
return rows, err
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]ListItem, len(rows))
returnIDs := make([]uint64, 0, len(rows))
for i, r := range rows {
items[i] = ListItem{ReturnMatch: r}
returnIDs = append(returnIDs, r.YeekeReturnItemID)
}
if len(returnIDs) == 0 {
return items, nil
}
// One bounded join query for every return item referenced on this page —
// never a per-row lookup.
var joined []struct {
ID uint64
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
Find(&joined).Error; err != nil {
return nil, err
}
byID := make(map[uint64]struct {
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
}, len(joined))
for _, j := range joined {
byID[j.ID] = struct {
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
}{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
items[i].DestroyDeadline = extra.DestroyDeadLine
}
}
return items, nil
}
func (s *Service) Detail(ctx context.Context, matchID uint64) (models.ReturnMatch, error) {
// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare
// screen (screen 3): both sides' fields and images side by side, normalized
// spec text for both, and the operation log.
type SYBDetailView struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
ShopName string `json:"shopName"`
ProductTitle string `json:"productTitle"`
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Quantity int64 `json:"quantity"`
ImageURL string `json:"imageUrl,omitempty"`
CurrentStage string `json:"currentStage"`
CurrentStageLabel string `json:"currentStageLabel"`
}
type YeekeDetailView struct {
YeekeReturnItemID uint64 `json:"yeekeReturnItemId"`
OrderSN string `json:"orderSn"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ShopName string `json:"shopName"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Quantity int64 `json:"quantity"`
Image string `json:"image,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
}
type MatchDetail struct {
Match models.ReturnMatch `json:"match"`
SYB *SYBDetailView `json:"syb,omitempty"`
Yeeke *YeekeDetailView `json:"yeeke,omitempty"`
NormalizedSYBSpec string `json:"normalizedSybSpec"`
NormalizedYeekeSpec string `json:"normalizedYeekeSpec"`
Logs []models.ReturnMatchLog `json:"logs"`
}
func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).First(&match, matchID).Error
return match, err
if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil {
return MatchDetail{}, err
}
detail := MatchDetail{Match: match}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil {
stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID})
var stage purchase.ProcessStageResult
if stageErr == nil {
stage = stages[syb.ID]
}
detail.SYB = &SYBDetailView{
SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID,
ShopName: syb.ShopName, ProductTitle: syb.ProductTitle,
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity,
ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label,
}
detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize)))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var item models.YeekeReturnItem
if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil {
var pkg models.YeekeReturnPackage
if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) {
return detail, pkgErr
}
detail.Yeeke = &YeekeDetailView{
YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID,
ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName,
Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine,
}
detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var logs []models.ReturnMatchLog
if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil {
return detail, err
}
detail.Logs = logs
return detail, nil
}
var errStateConflict = errors.New("return match state conflict")
@@ -2,6 +2,7 @@ package returnmatch
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@@ -227,6 +228,173 @@ func TestConfirmThenCancel_RestoresAvailability(t *testing.T) {
}
}
// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338
// review fix-2 regression: a purchase task gets created for the SYB product
// (moving its stage to task_created, which does not participate) AFTER
// BatchMatch's outer screening pass already judged it eligible but BEFORE
// matchOneWithLock's own re-check runs. The row must be skipped with
// stage_ineligible, not matched.
func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L", &deadline)
// Simulate the exact race the review flagged: BatchMatch's outer
// screening pass already computed `outcome` while the product was still
// participating (pdd_unlinked). Before matchOneWithLock's own re-check
// runs (which takes the same row lock purchase.create takes and
// recomputes the stage inside that transaction), something else moves
// the product out of a participating stage — here simulated directly by
// flipping it to a parse-failed/manual_action state, same effect as a
// purchase task having been created for it in the meantime.
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
t.Fatal(err)
}
outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"}
_, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester")
if !errors.Is(err, errStageNoLongerEligible) {
t.Fatalf("expected errStageNoLongerEligible, got %v", err)
}
var count int64
db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count)
if count != 0 {
t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count)
}
}
func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup match failed: %v %+v", err, resp)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" {
t.Fatalf("expected one matched log row: %+v", logs)
}
}
func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil {
t.Fatal(err)
}
if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil {
t.Fatal(err)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil {
t.Fatal(err)
}
wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled}
if len(logs) != len(wantActions) {
t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs)
}
for i, action := range wantActions {
if logs[i].Action != action {
t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs)
}
}
if logs[1].Detail != "看起来没问题" {
t.Fatalf("remark log must carry the remark text: %+v", logs[1])
}
}
func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
matchID := resp.Items[0].MatchID
tooLong := strings.Repeat("字", 501)
if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) {
t.Fatalf("expected errRemarkTooLong, got %v", err)
}
var match models.ReturnMatch
db.First(&match, matchID)
if match.Remark != "" {
t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark)
}
exactly500 := strings.Repeat("字", 500)
if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil {
t.Fatalf("exactly 500 runes must be accepted: %v", err)
}
}
func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), matchID)
if err != nil {
t.Fatal(err)
}
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" {
t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB)
}
if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" {
t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke)
}
if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) {
t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke)
}
if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec {
t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec)
}
if len(detail.Logs) != 2 {
t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs)
}
if detail.Logs[0].Action != models.ReturnMatchLogActionRemark {
t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs)
}
if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" {
t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match)
}
}
// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique
// constraint directly: two goroutines racing to insert an active match for
// the exact same SYB product must have exactly one winner, and the loser
@@ -268,3 +436,25 @@ func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) {
t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results)
}
}
func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil {
t.Fatalf("expected enriched list item: %+v", items)
}
}
+90 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -76,8 +77,23 @@ type ReturnItemRowDTO struct {
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
HasItem bool `json:"hasItem"`
// #338: match status/占用信息, joined from return_match (active match
// only). MatchStatus is "unmatched" when there is no active match.
MatchID uint64 `json:"matchId,omitempty"`
MatchStatus string `json:"matchStatus"`
OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"`
OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"`
OccupyingSYBStage string `json:"occupyingSybStage,omitempty"`
OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"`
}
const (
ReturnMatchFilterUnmatched = "unmatched"
ReturnMatchFilterMatched = "matched"
ReturnMatchFilterConfirmed = "confirmed"
)
func formatTimePtr(t *time.Time) *string {
if t == nil {
return nil
@@ -126,6 +142,12 @@ type itemRow struct {
ItemImage *string
ItemQuantity *int64
ItemLastSynced *time.Time
// #338 match columns, from the LEFT JOIN onto return_match/syb_product.
MatchID *uint64 `gorm:"column:match_id"`
MatchStatus *string `gorm:"column:match_status"`
OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"`
OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"`
}
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
@@ -166,6 +188,24 @@ func toItemRowDTO(r itemRow) ReturnItemRowDTO {
if r.ItemLastSynced != nil {
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
}
dto.MatchStatus = ReturnMatchFilterUnmatched
if r.MatchID != nil {
dto.MatchID = *r.MatchID
if r.MatchStatus != nil {
switch *r.MatchStatus {
case models.ReturnMatchStatusConfirmed:
dto.MatchStatus = ReturnMatchFilterConfirmed
default:
dto.MatchStatus = ReturnMatchFilterMatched
}
}
if r.OccupyingSYBProductID != nil {
dto.OccupyingSYBProductID = *r.OccupyingSYBProductID
}
if r.OccupyingSYBOrderCode != nil {
dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode
}
}
return dto
}
@@ -190,7 +230,12 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
}
base := db.Table("yeeke_return_package p").
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id")
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id").
// #338: single bounded LEFT JOIN onto the active match (if any) and
// the SYB product it occupies, so 匹配状态 filtering/column never
// needs a per-row lookup.
Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id").
Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_id")
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
}
@@ -235,6 +280,19 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
base = base.Where("i.variation_id = ?", v)
}
switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus {
case "":
// no filter
case ReturnMatchFilterUnmatched:
base = base.Where("m.id IS NULL")
case ReturnMatchFilterMatched:
base = base.Where("m.status = ?", models.ReturnMatchStatusMatched)
case ReturnMatchFilterConfirmed:
base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed)
default:
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"})
return
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
@@ -247,7 +305,8 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced",
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " +
"m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code",
).Order("p.create_time desc, i.id asc").
Offset((page - 1) * pageSize).Limit(pageSize)
if err := q.Find(&rows).Error; err != nil {
@@ -255,9 +314,37 @@ func (h Handler) ListReturnPackages(c *gin.Context) {
return
}
// #338: one bounded ProcessStages call for the distinct occupying SYB
// product ids on this page (never per-row), so the 「被占用 SYB 订单
// 商品」 column can also show its current stage.
sybIDs := make([]uint64, 0, len(rows))
seenSYB := make(map[uint64]bool, len(rows))
for _, r := range rows {
if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] {
seenSYB[*r.OccupyingSYBProductID] = true
sybIDs = append(sybIDs, *r.OccupyingSYBProductID)
}
}
var stages map[uint64]purchase.ProcessStageResult
if len(sybIDs) > 0 {
var stageErr error
stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs)
if stageErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
}
items := make([]ReturnItemRowDTO, 0, len(rows))
for _, r := range rows {
items = append(items, toItemRowDTO(r))
dto := toItemRowDTO(r)
if r.OccupyingSYBProductID != nil {
if stage, ok := stages[*r.OccupyingSYBProductID]; ok {
dto.OccupyingSYBStage = stage.Stage
dto.OccupyingSYBStageLabel = stage.Label
}
}
items = append(items, dto)
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
+113
View File
@@ -0,0 +1,113 @@
package yeeke
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
)
// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI
// support test: the yeeke returns list must be able to filter by match
// status and show which SYB order product occupies a matched return, using
// the single bounded LEFT JOIN added to ListReturnPackages (no N+1).
func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
deadline := now.Add(30 * 24 * time.Hour)
pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now}
if err := db.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now}
unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now}
if err := db.Create(&matchedItem).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unmatchedItem).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
match := models.ReturnMatch{
SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID,
ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID,
Status: models.ReturnMatchStatusMatched, MatchedAt: now,
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
handler := Handler{DB: db}
engine := gin.New()
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
do := func(req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w
}
// No filter: both rows present, matched row carries occupying info.
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil))
var resp itemRowListResp
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 2 {
t.Fatalf("expected 2 rows, got %+v", resp.Data)
}
byKey := map[string]ReturnItemRowDTO{}
for _, item := range resp.Data.Items {
byKey[item.Key] = item
}
matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)]
if matchedDTO.MatchStatus != ReturnMatchFilterMatched {
t.Fatalf("expected matched status: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID {
t.Fatalf("expected occupying SYB info: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBStageLabel == "" {
t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO)
}
unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)]
if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 {
t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO)
}
// matchStatus=unmatched filters down to just the unmatched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) {
t.Fatalf("expected only the unmatched row: %+v", resp.Data)
}
// matchStatus=matched filters down to just the matched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) {
t.Fatalf("expected only the matched row: %+v", resp.Data)
}
// Invalid value is rejected, not silently ignored.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code)
}
}
func decodeJSON(t *testing.T, body []byte, v any) {
t.Helper()
if err := json.Unmarshal(body, v); err != nil {
t.Fatalf("decode response: %v (%s)", err, body)
}
}
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listReturnMatches(params, options = {}) {
return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options })
}
export function getReturnMatch(matchId, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options })
}
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 cancelReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
}
export function remarkReturnMatch(matchId, data) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data })
}
+215 -5
View File
@@ -12,7 +12,7 @@
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></el-form-item>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
@@ -51,6 +51,26 @@
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="匹配到的退货商品" min-width="200">
<template #default="{ row }">
<template v-if="returnMatchByProductId[row.id]">
<div class="return-match-cell">
<el-image v-if="returnMatchByProductId[row.id].image" :src="returnMatchByProductId[row.id].image" fit="cover" class="thumb" preview-teleported />
<div>
<div>{{ returnMatchByProductId[row.id].orderSn || '—' }}</div>
<div class="muted">{{ returnMatchByProductId[row.id].variationName || '—' }}</div>
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
</div>
</div>
<div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
</div>
</template>
<span v-else class="muted">未匹配</span>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
@@ -162,6 +182,80 @@
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配退货批量结果 -->
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
<span>无候选 <strong>{{ returnMatchBatchResult.noCandidateCount }}</strong> 条</span>
<span>阶段不参与跳过 <strong>{{ returnMatchBatchResult.stageIneligibleCount }}</strong> 条</span>
<span v-if="returnMatchBatchResult.conflictCount" class="danger-text">并发冲突跳过 <strong>{{ returnMatchBatchResult.conflictCount }}</strong> 条</span>
</div>
<el-table :data="returnMatchBatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
<el-table-column label="SYB 商品 ID" prop="sybProductId" width="120" />
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'warning'">{{ row.matched ? '已匹配' : '跳过' }}</el-tag></template></el-table-column>
<el-table-column label="原因" min-width="260"><template #default="{ row }">{{ row.matched ? '—' : (row.reason || matchReasonMeta(row.reasonCode)) }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="row.matched && row.matchId" type="primary" link @click="openMatchDetail(row.matchId)">查看对比</el-button></template></el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="returnMatchBatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配详情对比 -->
<el-dialog v-model="matchDetail.open" title="退货匹配详情" width="900px" :close-on-click-modal="false">
<div v-loading="matchDetail.loading">
<template v-if="matchDetail.data">
<el-alert :title="'当前状态:' + matchStatusLabel(matchDetail.data.match.status)" :type="matchDetail.data.match.status === 'confirmed' ? 'success' : matchDetail.data.match.status === 'cancelled' ? 'info' : 'warning'" :closable="false" show-icon class="notice" />
<div class="split">
<div class="split-col">
<h3 class="section-title">SYB 商品</h3>
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ matchDetail.data.syb.productTitle }}</el-descriptions-item>
<el-descriptions-item label="目标颜色/尺码">{{ matchDetail.data.syb.targetColor || '—' }} / {{ matchDetail.data.syb.targetSize || '—' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.syb.quantity }}</el-descriptions-item>
<el-descriptions-item label="当前阶段"><el-tag :type="processMeta(matchDetail.data.syb.currentStage).type">{{ matchDetail.data.syb.currentStageLabel }}</el-tag></el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedSybSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">SYB 商品不存在或已删除</p>
</div>
<div class="split-col">
<h3 class="section-title">yeeke 退货商品</h3>
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.yeeke.shopName }}</el-descriptions-item>
<el-descriptions-item label="商品名">{{ matchDetail.data.yeeke.itemName }}</el-descriptions-item>
<el-descriptions-item label="规格文字">{{ matchDetail.data.yeeke.variationName }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.yeeke.quantity }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatMatchDeadline(matchDetail.data.yeeke.destroyDeadline) }}</el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedYeekeSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">退货商品不存在或已删除</p>
</div>
</div>
<h3 class="section-title">备注</h3>
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
<div class="drawer-actions">
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
</div>
<h3 class="section-title">操作日志</h3>
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatMatchDeadline(row.createdAt) }}</template></el-table-column>
<el-table-column label="动作" width="90" prop="action" />
<el-table-column label="操作人" width="100" prop="operator" />
<el-table-column label="详情" min-width="200" prop="detail" />
</el-table>
</template>
</div>
<template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
<div v-loading="detail.loading" class="drawer-body">
@@ -227,6 +321,7 @@ import { listDevices } from '@/api/goauto/devices'
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
import { createRequestId } from '@/utils/request-id'
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
@@ -255,6 +350,11 @@ export default {
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 },
matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' },
detail: { open: false, loading: false, item: null },
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
@@ -265,7 +365,8 @@ export default {
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
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: '待人工核对' }] },
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) },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
@@ -313,6 +414,7 @@ export default {
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)
} catch (error) {
if (generation !== this.loadGeneration) return
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
@@ -357,13 +459,19 @@ export default {
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return 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'])
return participating.has(this.purchaseReady(row).processStage)
},
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
// / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) },
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return 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)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
@@ -428,6 +536,107 @@ export default {
},
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
// ---------------- 退货匹配 (#338) ----------------
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
async loadReturnMatches(ids, generation = this.loadGeneration) {
this.returnMatchByProductId = {}
if (!ids.length) return
this.returnMatchLoading = true
try {
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
const map = {}
for (const item of (r.data?.items || [])) {
if (item.status === 'cancelled') continue
if (!map[item.sybProductId]) map[item.sybProductId] = item
}
this.returnMatchByProductId = map
} catch {
// 展示失败不影响主列表;用户可重新查询重试。
} finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false
}
},
matchReasonMeta(code) {
return {
stage_ineligible: '阶段不参与',
no_candidate: '无候选',
conflict: '并发冲突'
}[code] || code
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
if (!ids.length || this.returnMatchBatchLoading) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
const items = r.data.items || []
this.returnMatchBatchResult = {
open: true, items,
matchedCount: r.data.matchedCount || 0,
noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length,
stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length,
conflictCount: items.filter(i => i.reasonCode === 'conflict').length
}
await this.load()
} finally {
this.returnMatchBatchLoading = false
}
},
async openMatchDetail(matchId, focusRemark = false) {
this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' }
try {
const r = await getReturnMatch(matchId)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
} finally {
this.matchDetail.loading = false
}
if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus())
},
async refreshMatchDetail() {
const r = await getReturnMatch(this.matchDetail.id)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
await this.load()
},
async saveMatchRemark() {
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
ElMessage.success('已保存备注')
await this.refreshMatchDetail()
} finally {
this.matchDetail.saving = false
}
},
async confirmMatchDetail() {
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()
},
async quickCancelMatch(matchId) {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(matchId)
ElMessage.success('已取消匹配')
await this.load()
},
// ---------------- 批量创建采购任务 ----------------
async openSinglePurchase(row) {
this.$refs.productTable?.clearSelection()
@@ -625,6 +834,7 @@ export default {
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.return-match-cell{display:flex;gap:8px;align-items:flex-start}
.link{color:#1677ff;cursor:pointer}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
+28 -2
View File
@@ -22,6 +22,11 @@
</el-select>
</el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
<el-form-item label="匹配状态">
<el-select v-model="query.matchStatus" clearable placeholder="全部" style="width:130px">
<el-option v-for="item in matchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
@@ -64,6 +69,18 @@
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="匹配状态" min-width="100">
<template #default="{ row }"><el-tag :type="matchStatusMeta(row.matchStatus).type" effect="plain">{{ matchStatusMeta(row.matchStatus).label }}</el-tag></template>
</el-table-column>
<el-table-column label="被占用 SYB 订单商品" min-width="180">
<template #default="{ row }">
<template v-if="row.occupyingSybProductId">
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
@@ -139,8 +156,15 @@ export default {
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
shopOptions: [],
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' },
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' },
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
// #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里
// 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。
matchStatusOptions: [
{ label: '未匹配', value: 'unmatched' },
{ 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
@@ -159,6 +183,8 @@ export default {
// 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' } },
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' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
@@ -202,7 +228,7 @@ export default {
},
reset() {
this.createTimeRange = null
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }
this.load()
},
async openDetail(row) {