diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index e63b179..7645846 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -597,6 +597,8 @@ type SYBProduct struct { ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"` ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"` ExcludedAt *time.Time `json:"excludedAt,omitempty"` + // SYBStockCreatedAt is t_stock.created, not GoAuto's first-import CreatedAt. + SYBStockCreatedAt *time.Time `json:"sybStockCreatedAt,omitempty" gorm:"index"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 11d0168..023d22d 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -22,9 +22,10 @@ import ( // exist in this repo yet — see the package doc), so callers decode SYB's // response themselves and hand in only what a detail line requires. type OrderInput struct { - Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ - StockID uint64 // SYB stock/list internal `id`, e.g. 75104587 - ShopName string + Code string // SYB stock/list `code`, e.g. 260728TB95MJTQ + 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 } // DetailInput mirrors one element of SYB's `details[]` array from @@ -137,7 +138,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, - ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle, + SYBStockCreatedAt: order.SYBStockCreatedAt, + ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle, TargetColor: parsed.Color, TargetSize: parsed.Size, Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice), ImageURL: buildImageURL(detail.ProductThumb), @@ -191,6 +193,11 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta "quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL, "parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON, } + if order.SYBStockCreatedAt != nil { + updates["syb_stock_created_at"] = *order.SYBStockCreatedAt + } else { + record.SYBStockCreatedAt = existing.SYBStockCreatedAt + } // 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/apply_test.go b/server/app/goauto/sybimport/apply_test.go index 5db4b5e..7a374bd 100644 --- a/server/app/goauto/sybimport/apply_test.go +++ b/server/app/goauto/sybimport/apply_test.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "testing" + "time" "go-admin/app/goauto/migrations" "go-admin/app/goauto/models" @@ -128,11 +129,15 @@ func TestApplyDetailKeepsMultiUnitQuantityAsOneRow(t *testing.T) { func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) { db := openTestDB(t) order, detail := realOrder(), realDetailA() + firstSource := time.Date(2026, 9, 27, 23, 59, 59, 0, time.FixedZone("SYB", 8*3600)) + order.SYBStockCreatedAt = &firstSource first, err := sybimport.ApplyDetail(context.Background(), db, order, detail) if err != nil { t.Fatalf("first apply: %v", err) } detail.ProductQty = 2 // simulate the source value changing between imports + newSource := firstSource.Add(time.Second) + order.SYBStockCreatedAt = &newSource second, err := sybimport.ApplyDetail(context.Background(), db, order, detail) if err != nil { t.Fatalf("second apply: %v", err) @@ -143,6 +148,13 @@ func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) { if second.SYBProduct.ID != first.SYBProduct.ID { t.Fatal("re-import must update the same row, not create a new one") } + var stored models.SYBProduct + if err := db.First(&stored, first.SYBProduct.ID).Error; err != nil { + t.Fatal(err) + } + if stored.SYBStockCreatedAt == nil || !stored.SYBStockCreatedAt.Equal(newSource) || !stored.CreatedAt.Equal(first.SYBProduct.CreatedAt) { + t.Fatalf("re-import must update source time, preserve GoAuto creation: %+v", stored) + } var count int64 db.Model(&models.SYBProduct{}).Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).Count(&count) if count != 1 { diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index c3c0eee..2bb38d7 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -90,10 +90,10 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp return ListResponse{}, err } if createdFrom != nil { - query = query.Where("created_at >= ?", *createdFrom) + query = query.Where("syb_stock_created_at >= ?", *createdFrom) } if createdTo != nil { - query = query.Where("created_at < ?", *createdTo) + query = query.Where("syb_stock_created_at < ?", *createdTo) } if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" { if len([]rune(request.ShopName)) > 255 { @@ -179,8 +179,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp } // createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time -// range. The bounds are interpreted in the server's local timezone, matching -// the timestamps written by GORM for this service. +// range. SYB stock.created is an Asia/Shanghai wall-clock timestamp; use that +// timezone explicitly rather than depending on the server process timezone. func createdAtRange(from, to string) (*time.Time, *time.Time, error) { from = strings.TrimSpace(from) to = strings.TrimSpace(to) @@ -191,7 +191,7 @@ func createdAtRange(from, to string) (*time.Time, *time.Time, error) { if value == "" { return nil, nil } - parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + parsed, err := time.ParseInLocation("2006-01-02", value, sybStockLocation) if err != nil { return nil, invalidRequest(label + " 必须是 YYYY-MM-DD") } diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go index e104afc..6e43d1c 100644 --- a/server/app/goauto/sybimport/service_test.go +++ b/server/app/goauto/sybimport/service_test.go @@ -127,39 +127,58 @@ func TestServiceListCapsPageSizeAt500(t *testing.T) { func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) { db := openTestDB(t) order := realOrder() - for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} { + for i, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00", "2026-09-29 23:59:59", "2026-09-30 00:00:00"} { rowOrder := order rowOrder.Code = fmt.Sprintf("CREATED-%d", i) rowOrder.StockID += uint64(i) + sourceCreated, err := time.ParseInLocation("2006-01-02 15:04:05", stamp, time.FixedZone("SYB", 8*60*60)) + if err != nil { + t.Fatal(err) + } + rowOrder.SYBStockCreatedAt = &sourceCreated detail := realDetailA() detail.ID += uint64(i) result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail) if err != nil { t.Fatal(err) } - created, err := time.ParseInLocation("2006-01-02", day, time.Local) - if err != nil { - t.Fatal(err) - } - if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil { + // Import time must not determine whether the row matches source dates. + imported := time.Date(2026, 9, 28, 0, 5, 0, 0, time.FixedZone("SYB", 8*60*60)) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", imported).Error; err != nil { t.Fatal(err) } } + missing := realOrder() + missing.Code = "CREATED-MISSING" + missing.StockID += 100 + missingDetail := realDetailA() + missingDetail.ID += 100 + if _, err := sybimport.ApplyDetail(context.Background(), db, missing, missingDetail); err != nil { + t.Fatal(err) + } service := sybimport.NewService(db) - between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"}) + between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29"}) if err != nil || between.Total != 2 { t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err) } - fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"}) + combined, err := service.List(context.Background(), sybimport.ListRequest{ + CreatedFrom: "2026-09-28", CreatedTo: "2026-09-29", ShopName: order.ShopName, + OrderCodes: []string{"CREATED-1", "CREATED-3"}, ParseStatus: models.SYBParseStatusSuccess, + Page: 1, PageSize: 1, + }) + if err != nil || combined.Total != 1 || len(combined.Items) != 1 || combined.Items[0].OrderCode != "CREATED-1" { + t.Fatalf("date and existing filters must compose before pagination: %+v err=%v", combined, err) + } + fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30"}) if err != nil || fromOnly.Total != 1 { t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err) } - toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"}) + toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-27"}) if err != nil || toOnly.Total != 1 { t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err) } - if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest { + if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-30", CreatedTo: "2026-09-27"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest { t.Fatalf("reversed created date range should be rejected: %v", err) } } diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index c28b312..624c0b1 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -45,6 +45,22 @@ const ( maxSyncDays = 31 ) +var sybStockLocation = time.FixedZone("SYB UTC+8", 8*60*60) + +// parseSYBStockCreatedAt reads the same t_stock.created value used by SYB's +// date filter. Never derive it from the order code or local import timestamp. +func parseSYBStockCreatedAt(raw map[string]any) (*time.Time, error) { + value, ok := raw["created"].(string) + if !ok || strings.TrimSpace(value) == "" { + return nil, errors.New("SYB 货运单缺少来源创建时间") + } + created, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(value), sybStockLocation) + if err != nil || created.IsZero() { + return nil, errors.New("SYB 货运单来源创建时间格式无效") + } + return &created, nil +} + // SyncReport summarises one sync run. type SyncReport struct { From string `json:"from"` @@ -381,14 +397,22 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err // ApplyDetail call and its own transaction — a several-thousand-row sync must // not sit in one long-held transaction. func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error { + if row.ID != detail.ID { + return fmt.Errorf("货运单列表与明细 ID 不一致: %d/%d", row.ID, detail.ID) + } + stockCreated, err := parseSYBStockCreatedAt(row.Raw) + if err != nil { + return fmt.Errorf("货运单 %d: %w", row.ID, err) + } var filters sybproductfilter.RuleSet if len(sets) > 0 { filters = sets[0] } order := OrderInput{ - Code: detail.Code, - StockID: uint64(detail.ID), - ShopName: stringField(row.Raw, "shopName"), + Code: detail.Code, + StockID: uint64(detail.ID), + ShopName: stringField(row.Raw, "shopName"), + SYBStockCreatedAt: stockCreated, } if order.Code == "" { order.Code = row.Code diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go index 983c444..8e11eb3 100644 --- a/server/app/goauto/sybimport/sync_test.go +++ b/server/app/goauto/sybimport/sync_test.go @@ -109,6 +109,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server { "id": float64(1000 + i), "code": fmt.Sprintf("ORD%s%03d", strings.ReplaceAll(date, "-", ""), i), "shopName": f.shopFor(i), + "created": date + " 10:00:00", }) } if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 { @@ -118,7 +119,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server { if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil { rows = nil for _, id := range ids { - rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)}) + rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0), "created": date + " 10:00:00"}) } } } @@ -239,6 +240,27 @@ func TestSyncIsIdempotentAcrossRuns(t *testing.T) { if count != 5 { t.Fatalf("重跑不应产生重复行,实际 %d 行", count) } + var first models.SYBProduct + if err := db.Order("id").First(&first).Error; err != nil { + t.Fatal(err) + } + if first.SYBStockCreatedAt == nil || first.SYBStockCreatedAt.In(sybStockLocation).Format("2006-01-02 15:04:05") != "2026-08-01 10:00:00" { + t.Fatalf("source timestamp not saved from stock list: %+v", first.SYBStockCreatedAt) + } +} + +func TestParseSYBStockCreatedAtRequiresSourceProof(t *testing.T) { + for _, raw := range []map[string]any{nil, {}, {"created": ""}, {"created": "2026/09/28"}, {"created": 123}} { + if _, err := parseSYBStockCreatedAt(raw); err == nil { + t.Fatalf("invalid source timestamp accepted: %v", raw) + } + } + for _, stamp := range []string{"2026-09-27 23:59:59", "2026-09-28 00:00:00"} { + got, err := parseSYBStockCreatedAt(map[string]any{"created": stamp}) + if err != nil || got.In(sybStockLocation).Format("2006-01-02 15:04:05") != stamp { + t.Fatalf("parse %s: %v %v", stamp, got, err) + } + } } // 超过上限必须在**写库之前**失败,否则会留下一半数据还报错。 diff --git a/server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go b/server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go new file mode 100644 index 0000000..60c8bcb --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go @@ -0,0 +1,33 @@ +package version_local + +import ( + "runtime" + + "go-admin/app/goauto/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" +) + +// #352 adds the nullable, indexed SYB source timestamp. Historical rows are +// intentionally left NULL until a bounded SYB re-sync supplies source proof. +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBStockCreatedAt) +} + +func migrateSYBStockCreatedAt(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") { + if err := tx.Migrator().AddColumn(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil { + return err + } + } + if !tx.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") { + if err := tx.Migrator().CreateIndex(&models.SYBProduct{}, "SYBStockCreatedAt"); err != nil { + return err + } + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}