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 1/2] 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 @@