From d6639a4a0d6954b678822fbc1ce2a9603105073f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 5 Oct 2026 17:51:07 +0800 Subject: [PATCH] feat(#357): display SYB cancellation status without changing procurement --- server/app/goauto/models/schema.go | 3 + server/app/goauto/sybimport/apply.go | 12 +- server/app/goauto/sybimport/cancel_status.go | 26 +++ .../goauto/sybimport/cancel_status_test.go | 155 ++++++++++++++++++ server/app/goauto/sybimport/sync.go | 5 + .../1791200000000_syb_cancel_status.go | 29 ++++ .../1791200000000_syb_cancel_status_test.go | 60 +++++++ web/src/views/goauto/syb-products/index.vue | 7 +- web/tests/e2e/syb-cancel-status.spec.ts | 60 +++++++ web/tests/e2e/syb-product-layout.spec.ts | 7 +- 10 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 server/app/goauto/sybimport/cancel_status.go create mode 100644 server/app/goauto/sybimport/cancel_status_test.go create mode 100644 server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go create mode 100644 server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go create mode 100644 web/tests/e2e/syb-cancel-status.spec.ts diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 7645846..d9f93d1 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -535,6 +535,9 @@ type SYBProduct struct { // kept for reference when calling back into SYB. StockID uint64 `json:"stockId" gorm:"not null"` ShopName string `json:"shopName" gorm:"size:255;not null;default:''"` + // Source cancellation is display-only; nil means not acquired, not active. + SYBIsCancelled *bool `json:"sybIsCancelled"` + SYBStatusSyncedAt *time.Time `json:"sybStatusSyncedAt"` // ShopeeItemID is `details[].productId` as a string, matching // shopee_product.shopee_item_id's type. ShopeeProductID links to the diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 023d22d..312b456 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -26,6 +26,8 @@ type OrderInput struct { StockID uint64 // SYB stock/list internal `id`, e.g. 75104587 ShopName string SYBStockCreatedAt *time.Time // SYB stock/list `created`; nil for legacy/manual test inputs + SYBIsCancelled *bool + SYBStatusSyncedAt *time.Time } // DetailInput mirrors one element of SYB's `details[]` array from @@ -139,7 +141,8 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta record := models.SYBProduct{ OrderCode: order.Code, DetailID: detail.ID, StockID: order.StockID, ShopName: order.ShopName, SYBStockCreatedAt: order.SYBStockCreatedAt, - ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle, + SYBIsCancelled: order.SYBIsCancelled, SYBStatusSyncedAt: order.SYBStatusSyncedAt, + ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle, TargetColor: parsed.Color, TargetSize: parsed.Size, Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice), ImageURL: buildImageURL(detail.ProductThumb), @@ -198,6 +201,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta } else { record.SYBStockCreatedAt = existing.SYBStockCreatedAt } + if order.SYBIsCancelled != nil { + updates["syb_is_cancelled"] = *order.SYBIsCancelled + updates["syb_status_synced_at"] = order.SYBStatusSyncedAt + } else { + record.SYBIsCancelled = existing.SYBIsCancelled + record.SYBStatusSyncedAt = existing.SYBStatusSyncedAt + } // An identical re-import keeps a valid AI decision. Changed source, // link, or a newly deterministic parse invalidates it atomically. preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess && diff --git a/server/app/goauto/sybimport/cancel_status.go b/server/app/goauto/sybimport/cancel_status.go new file mode 100644 index 0000000..163449b --- /dev/null +++ b/server/app/goauto/sybimport/cancel_status.go @@ -0,0 +1,26 @@ +package sybimport + +import "encoding/json" + +// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer +// anything from status/orderStatus, missing fields or undocumented values. +func parseSYBIsCancelled(raw map[string]any) *bool { + var number float64 + switch value := raw["isCancel"].(type) { + case float64: // encoding/json's default map decoding + number = value + case json.Number: + var err error + number, err = value.Float64() + if err != nil { + return nil + } + default: + return nil + } + if number != 0 && number != 1 { + return nil + } + cancelled := number == 1 + return &cancelled +} diff --git a/server/app/goauto/sybimport/cancel_status_test.go b/server/app/goauto/sybimport/cancel_status_test.go new file mode 100644 index 0000000..8491e3d --- /dev/null +++ b/server/app/goauto/sybimport/cancel_status_test.go @@ -0,0 +1,155 @@ +package sybimport + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" +) + +func TestParseSYBIsCancelled(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"}, + {`{}`, "null"}, {`{"isCancel":null}`, "null"}, + {`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"}, + {`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"}, + {`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "null"}, + } { + var raw map[string]any + if err := json.Unmarshal([]byte(tc.input), &raw); err != nil { + t.Fatal(err) + } + got, _ := json.Marshal(parseSYBIsCancelled(raw)) + if string(got) != tc.want { + t.Errorf("%s: %s, want %s", tc.input, got, tc.want) + } + } + if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got { + t.Fatal("json.Number") + } +} + +func TestSyncCancellationDisplayAndListContract(t *testing.T) { + db := newSyncTestDB(t) + ctx := context.Background() + row := sybclient.StockRow{ID: 71, Code: "MOCK-CANCEL", Raw: map[string]any{"shopName": "测试店铺", "created": "2026-10-01 12:00:00"}} + detail := sybclient.StockDetail{ID: row.ID, Code: row.Code, Details: []sybclient.DetailItem{ + {ID: 711, ProductID: 701, ProductQty: 1, ProductSpec: "黑色,XL"}, + {ID: 712, ProductID: 702, ProductQty: 1, ProductSpec: "白色,L"}, + }} + var previous *time.Time + for _, tc := range []struct { + raw string + want *bool + }{ + {`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)}, + {`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)}, + {`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)}, + } { + var values map[string]any + if err := json.Unmarshal([]byte(tc.raw), &values); err != nil { + t.Fatal(err) + } + delete(row.Raw, "isCancel") + for key, value := range values { + row.Raw[key] = value + } + if err := applyStockDetail(ctx, db, row, detail, &SyncReport{}); err != nil { + t.Fatal(err) + } + var records []models.SYBProduct + if err := db.Order("id").Find(&records).Error; err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatal("must keep both details") + } + valid := parseSYBIsCancelled(values) != nil + for _, record := range records { + got, _ := json.Marshal(record.SYBIsCancelled) + want, _ := json.Marshal(tc.want) + if string(got) != string(want) { + t.Fatalf("%s: got %s want %s", tc.raw, got, want) + } + if tc.want == nil && record.SYBStatusSyncedAt != nil { + t.Fatal("unknown must have no timestamp") + } + if tc.want != nil && record.SYBStatusSyncedAt == nil { + t.Fatal("known must have timestamp") + } + if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) { + t.Fatal("missing/invalid must preserve time") + } + if record.PDDExcluded { + t.Fatal("display status must not exclude procurement") + } + } + if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) { + t.Fatal("same order must use same timestamp") + } + previous = records[0].SYBStatusSyncedAt + } + // List and detail reuse the model, including false/null (never omitempty). + if err := db.Model(&models.SYBProduct{}).Where("detail_id = ?", 712).Updates(map[string]any{"syb_is_cancelled": nil, "syb_status_synced_at": nil}).Error; err != nil { + t.Fatal(err) + } + gin.SetMode(gin.TestMode) + router := gin.New() + handler := Handler{DB: db} + router.GET("/products", handler.List) + router.GET("/products/:productId", handler.Detail) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("GET", "/products?purchaseType=all", nil)) + if response.Code != 200 { + t.Fatalf("list: %d %s", response.Code, response.Body.String()) + } + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload.Data.Items) != 2 { + t.Fatal("list count") + } + for _, item := range payload.Data.Items { + value, present := item["sybIsCancelled"] + if !present { + t.Fatal("nullable field absent") + } + if item["detailId"] == float64(711) && value != false { + t.Fatal("false omitted") + } + if item["detailId"] == float64(712) && value != nil { + t.Fatal("unknown not null") + } + if _, present := item["sybStatusSyncedAt"]; !present { + t.Fatal("timestamp absent") + } + } + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil)) + if response.Code != 200 { + t.Fatalf("detail: %d", response.Code) + } + var detailPayload struct { + Data struct { + Item models.SYBProduct `json:"item"` + } `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &detailPayload); err != nil { + t.Fatal(err) + } + if detailPayload.Data.Item.SYBIsCancelled == nil || *detailPayload.Data.Item.SYBIsCancelled || detailPayload.Data.Item.SYBStatusSyncedAt == nil { + t.Fatal("detail must preserve false and time") + } +} + +func boolPointer(v bool) *bool { return &v } diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index 624c0b1..60a46a4 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -413,6 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, StockID: uint64(detail.ID), ShopName: stringField(row.Raw, "shopName"), SYBStockCreatedAt: stockCreated, + SYBIsCancelled: parseSYBIsCancelled(row.Raw), + } + if order.SYBIsCancelled != nil { + now := time.Now().UTC() + order.SYBStatusSyncedAt = &now } if order.Code == "" { order.Code = row.Code diff --git a/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go new file mode 100644 index 0000000..15e95bb --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status.go @@ -0,0 +1,29 @@ +package version_local + +import ( + "runtime" + + "go-admin/app/goauto/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" +) + +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBCancelStatus) +} + +// Nullable, display-only source evidence. Never assume legacy rows are active. +func migrateSYBCancelStatus(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + for _, column := range []string{"SYBIsCancelled", "SYBStatusSyncedAt"} { + if !tx.Migrator().HasColumn(&models.SYBProduct{}, column) { + if err := tx.Migrator().AddColumn(&models.SYBProduct{}, column); err != nil { + return err + } + } + } + return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go new file mode 100644 index 0000000..2caabcf --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791200000000_syb_cancel_status_test.go @@ -0,0 +1,60 @@ +package version_local + +import ( + common "go-admin/common/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "testing" +) + +func TestMigrateSYBCancelStatusIdempotentAndNullable(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:syb_cancel_migration?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { sqlDB.Close() }) + if err := db.Exec("CREATE TABLE syb_product (id integer primary key)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO syb_product(id) VALUES(1)").Error; err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := migrateSYBCancelStatus(db, "test_cancel_status"); err != nil { + t.Fatal(err) + } + } + var row struct { + SYBIsCancelled *bool + SYBStatusSyncedAt *string + } + if err := db.Table("syb_product").Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.SYBIsCancelled != nil || row.SYBStatusSyncedAt != nil { + t.Fatal("legacy must remain unknown") + } + var count int64 + if err := db.Model(&common.Migration{}).Where("version = ?", "test_cancel_status").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("duplicate migration record") + } + if err := db.Exec("UPDATE syb_product SET syb_is_cancelled = 0 WHERE id = 1").Error; err != nil { + t.Fatal(err) + } + if err := migrateSYBCancelStatus(db, "test_cancel_status"); err != nil { + t.Fatal(err) + } + if err := db.Table("syb_product").Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.SYBIsCancelled == nil || *row.SYBIsCancelled { + t.Fatal("rerun must retain explicit false") + } +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index d3e3a7e..734a2ae 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -40,6 +40,9 @@ + + + @@ -73,7 +76,7 @@
{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}
- +