diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index e9aeb32..e3fdde8 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -79,6 +79,7 @@ func MigratedModels() []any { &models.PDDProductReplacement{}, &models.PDDProductReplacementItem{}, &models.PDDProductReplacementWorkerLease{}, + &models.ReturnMatch{}, } } diff --git a/server/app/goauto/models/return_match.go b/server/app/goauto/models/return_match.go new file mode 100644 index 0000000..baab66c --- /dev/null +++ b/server/app/goauto/models/return_match.go @@ -0,0 +1,71 @@ +package models + +import "time" + +// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so +// the return can be reused instead of buying the item again on PDD. A match +// is "active" (still blocking purchase creation and occupying the return) +// while Status is matched or confirmed; cancelling clears both Active* +// columns so the unique index frees up both sides for a future rematch +// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许). +// +// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique- +// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase: +// MySQL unique indexes allow any number of NULL rows, so only the currently +// active match for a given side occupies the slot, and a concurrent second +// insert for the same side fails the unique constraint instead of double- +// booking it. +const ( + ReturnMatchStatusMatched = "matched" + ReturnMatchStatusConfirmed = "confirmed" + ReturnMatchStatusCancelled = "cancelled" +) + +type ReturnMatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + + SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"` + YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"` + + // ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above + // while Status is matched/confirmed, and are cleared to NULL on cancel. + ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"` + ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"` + + Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"` + + // Snapshots of the normalized spec text on both sides at match time, kept + // for audit and for the compare screen even if the source rows change + // later. + SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"` + YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"` + NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"` + + // PreviousProcessStage records what the SYB product's computed process + // stage was immediately before this match, so cancel can report/restore + // context; the actual restore is a pure recomputation (no stage is + // stored on syb_product), this is for audit/display only. + PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"` + + DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"` + + Remark string `json:"remark" gorm:"size:500;not null;default:''"` + + MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"` + MatchedAt time.Time `json:"matchedAt"` + ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"` + ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` + CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (ReturnMatch) TableName() string { return "return_match" } + +// IsActive reports whether the match currently blocks purchase creation and +// occupies its return item. +func (m ReturnMatch) IsActive() bool { + return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed +}