feat(#67): add Shopee order snapshots to purchase tasks
This commit is contained in:
@@ -67,6 +67,9 @@ func TestPurchaseContractTablesAreMigrated(t *testing.T) {
|
||||
t.Fatalf("missing table %s", table)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasColumn(&models.PurchaseTask{}, "ShopeeOrderNoSnapshot") {
|
||||
t.Fatal("purchase_task missing shopee_order_no_snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseSYBForeignKeyDoesNotUseMySQLIncompatibleCheck(t *testing.T) {
|
||||
|
||||
@@ -89,6 +89,7 @@ type PurchaseTask struct {
|
||||
AccountRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_purchase_task_running_account,priority:2"`
|
||||
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot" gorm:"size:64;not null"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot" gorm:"size:64;not null;default:'';index"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot" gorm:"size:500;not null;default:''"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot" gorm:"size:255;not null;default:''"`
|
||||
PDDURLSnapshot string `json:"pddUrlSnapshot" gorm:"type:text;not null"`
|
||||
|
||||
@@ -352,7 +352,8 @@ type SYBSession struct {
|
||||
// available sample data, so a global-uniqueness assumption is unverified.
|
||||
type SYBProduct struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
// OrderCode is the shipment order's `code` (e.g. 260728TB95MJTQ).
|
||||
// OrderCode is SYB's source `code` and represents the Shopee order number
|
||||
// shown to procurement users (e.g. 260728TB95MJTQ).
|
||||
OrderCode string `json:"orderCode" gorm:"size:64;not null;uniqueIndex:ux_syb_product_order_detail,priority:1"`
|
||||
// DetailID is `details[].id` from SYB, e.g. 145306175.
|
||||
DetailID uint64 `json:"detailId" gorm:"not null;uniqueIndex:ux_syb_product_order_detail,priority:2"`
|
||||
|
||||
@@ -18,6 +18,7 @@ type AdminListRequest struct {
|
||||
Status string
|
||||
ExecutionMode string
|
||||
SYBProductID uint64
|
||||
ShopeeOrderNo string
|
||||
PDDOrderNo string
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ type AdminTaskItem struct {
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
||||
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
||||
@@ -128,6 +130,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.SYBProductID > 0 {
|
||||
query = query.Where("syb_product_id = ?", req.SYBProductID)
|
||||
}
|
||||
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
|
||||
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
|
||||
}
|
||||
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
|
||||
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
|
||||
}
|
||||
@@ -205,7 +210,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr
|
||||
item := AdminTaskItem{
|
||||
ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status,
|
||||
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
|
||||
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot,
|
||||
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
||||
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
|
||||
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
|
||||
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
|
||||
|
||||
@@ -71,6 +71,27 @@ func TestAdminListAndDetailExposeSafePurchaseViews(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListFiltersByShopeeOrderNumberSnapshot(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatalf("create live task: %v", err)
|
||||
}
|
||||
list, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "YB-"})
|
||||
if err != nil {
|
||||
t.Fatalf("admin list: %v", err)
|
||||
}
|
||||
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != task.ID || list.Items[0].ShopeeOrderNoSnapshot != f.syb.OrderCode {
|
||||
t.Fatalf("unexpected order query result: %+v", list)
|
||||
}
|
||||
empty, err := s.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, ShopeeOrderNo: "NOT-FOUND"})
|
||||
if err != nil || empty.Total != 0 {
|
||||
t.Fatalf("unexpected empty result: %+v %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminQueryValidationAndNotFound(t *testing.T) {
|
||||
s := testService(testDB(t))
|
||||
if _, err := s.AdminList(context.Background(), AdminListRequest{Status: "unknown"}); code(err) != CodeInvalidRequest {
|
||||
|
||||
@@ -51,7 +51,8 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
|
||||
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
|
||||
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -105,11 +105,12 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR
|
||||
task, replayed, createErr := s.Create(ctx, CreateRequest{
|
||||
RequestID: candidate.requestID, ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
SYBProductID: &sybID, DeviceID: candidate.source.DeviceID,
|
||||
ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: candidate.preview.MinUnitPriceCent,
|
||||
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
|
||||
Currency: candidate.preview.Currency,
|
||||
RuleSnapshot: purchasecontract.DefaultLiveRule(),
|
||||
shopeeOrderNoSnapshotOverride: candidate.source.ShopeeOrderNoSnapshot,
|
||||
ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: candidate.preview.MinUnitPriceCent,
|
||||
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
|
||||
Currency: candidate.preview.Currency,
|
||||
RuleSnapshot: purchasecontract.DefaultLiveRule(),
|
||||
})
|
||||
item := response.Items[candidate.resultIndex]
|
||||
if createErr != nil {
|
||||
|
||||
@@ -42,6 +42,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
|
||||
|
||||
first, err := service.BatchRetry(context.Background(), request)
|
||||
@@ -64,6 +67,9 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID {
|
||||
t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask)
|
||||
}
|
||||
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
|
||||
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
|
||||
}
|
||||
|
||||
replay, err := service.BatchRetry(context.Background(), request)
|
||||
if err != nil {
|
||||
|
||||
@@ -191,8 +191,12 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
return fail(CodeInvalidRequest, "数量、价格区间或币种无效")
|
||||
}
|
||||
required, _ := json.Marshal(purchasecontract.RequiredCapabilities(rule))
|
||||
shopeeOrderNoSnapshot := syb.OrderCode
|
||||
if req.shopeeOrderNoSnapshotOverride != "" {
|
||||
shopeeOrderNoSnapshot = req.shopeeOrderNoSnapshotOverride
|
||||
}
|
||||
out = models.PurchaseTask{SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
|
||||
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
|
||||
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
|
||||
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
|
||||
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
|
||||
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
|
||||
|
||||
@@ -113,6 +113,18 @@ func TestRehearsalCannotContainOrderActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
task, err := createLive(t, testService(db), f)
|
||||
if err != nil {
|
||||
t.Fatalf("create live task: %v", err)
|
||||
}
|
||||
if task.ShopeeOrderNoSnapshot != f.syb.OrderCode {
|
||||
t.Fatalf("order number snapshot=%q want %q", task.ShopeeOrderNoSnapshot, f.syb.OrderCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRehearsalPreservesParameterizedRuleSnapshot(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
|
||||
@@ -59,6 +59,10 @@ type CreateRequest struct {
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
|
||||
// shopeeOrderNoSnapshotOverride is server-only. Batch retry uses the
|
||||
// immutable source-task value instead of accepting a client-supplied
|
||||
// identity snapshot.
|
||||
shopeeOrderNoSnapshotOverride string
|
||||
}
|
||||
|
||||
type ActionRequest struct {
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This additive #67 migration records the Shopee order number that explains
|
||||
// each purchase task. Existing tasks are backfilled from their immutable SYB
|
||||
// product relation when it is still available; unresolved legacy rows stay
|
||||
// empty instead of receiving a fabricated identifier.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseShopeeOrderSnapshot)
|
||||
}
|
||||
|
||||
func migratePurchaseShopeeOrderSnapshot(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillPurchaseShopeeOrderSnapshot(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func backfillPurchaseShopeeOrderSnapshot(db *gorm.DB) error {
|
||||
return db.Exec(`UPDATE purchase_task
|
||||
SET shopee_order_no_snapshot = COALESCE((
|
||||
SELECT syb_product.order_code
|
||||
FROM syb_product
|
||||
WHERE syb_product.id = purchase_task.syb_product_id
|
||||
), '')
|
||||
WHERE shopee_order_no_snapshot = ''
|
||||
AND syb_product_id IS NOT NULL`).Error
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBackfillPurchaseShopeeOrderSnapshot(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:purchase-order-snapshot?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE syb_product (id INTEGER PRIMARY KEY, order_code TEXT NOT NULL)`,
|
||||
`CREATE TABLE purchase_task (id INTEGER PRIMARY KEY, syb_product_id INTEGER, shopee_order_no_snapshot TEXT NOT NULL DEFAULT '')`,
|
||||
`INSERT INTO syb_product (id, order_code) VALUES (1, '260728TB95MJTQ')`,
|
||||
`INSERT INTO purchase_task (id, syb_product_id, shopee_order_no_snapshot) VALUES (1, 1, ''), (2, NULL, ''), (3, 1, 'IMMUTABLE')`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = backfillPurchaseShopeeOrderSnapshot(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint64
|
||||
Snapshot string `gorm:"column:shopee_order_no_snapshot"`
|
||||
}
|
||||
if err = db.Table("purchase_task").Select("id, shopee_order_no_snapshot").Order("id").Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 || rows[0].Snapshot != "260728TB95MJTQ" || rows[1].Snapshot != "" || rows[2].Snapshot != "IMMUTABLE" {
|
||||
t.Fatalf("unexpected backfill rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user