fix(#352): filter SYB products by source stock created time
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超过上限必须在**写库之前**失败,否则会留下一半数据还报错。
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user