Compare commits

..
12 changed files with 197 additions and 29 deletions
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 88ed799e92f64a73b3bd9472ab6c0b06fa080b37
synchronized_at: 2026-09-29T09:48:30Z
wiki_revision: 04a4bcf81f2f2912d101d1aa1c2d8eb802c5719e
synchronized_at: 2026-09-30T01:50:27Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -137,6 +137,7 @@ Android Portal/Agent
| 虾皮商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700600000_shopee_product_archive.go` |
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
| SYB 来源货运单创建时间(#352,待迁移与发布) | `syb_product.syb_stock_created_at` 为可空且带索引的 `t_stock.created`,由 `sybimport/sync.go` 从列表取值,经 `apply.go` 写入;`service.go` 的日期筛选使用该字段。迁移 `server/cmd/migrate/migration/version-local/1789810000000_syb_stock_created_at.go` 仅加列和索引,不改历史行;历史由有界 SYB 重同步补齐。 |
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
+6 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 5d9810286d76c52a90902c27a5316f39164f36e0
synchronized_at: 2026-09-29T09:48:45Z
wiki_revision: 76037bfdf5e986c478055e7338c21ee1ba561bad
synchronized_at: 2026-09-30T01:50:31Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -57,6 +57,10 @@ synchronized_at: 2026-09-22T02:56:27Z
## SYB 商品明细
### 来源创建时间(#352 目标契约,待迁移与发布)
Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(UTC+8 本地日期)筛选关联商品明细,起止日均包含;这不是商品明细独立创建时间。同步新增或重同步读取列表 `created` 并存入 `syb_product.syb_stock_created_at`;`syb_product.created_at` 保留 GoAuto 首次入库审计时间。来源值缺失或格式异常时明确报错,不能用订单号或入库时间推断。历史空值不命中日期筛选;迁移后按不超过 31 天且受货运单数量上限约束的区间重同步补齐,先后统计空值数量,失败区间可幂等重跑,仍无法取得来源值的记录保持空值并报告。
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 18260ec2725eaeb4dcf2543422d58ee4ede8b981
synchronized_at: 2026-09-29T09:50:08Z
wiki_revision: f92513e7184fe704d2b9d1c6606b16a86d8d3fb8
synchronized_at: 2026-09-30T01:58:59Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -89,6 +89,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
#352 目标契约(待迁移与发布):SYB 商品列表沿用 `createdFrom` / `createdTo` 的 `YYYY-MM-DD` 入参和 Admin「创建时间」标签,但条件改为所属货运单 `t_stock.created` 的 UTC+8 日期,使用左闭右开区间并包含结束日。该条件与店铺、订单号、解析状态、处理阶段、采购类型及分页组合;`sybStockCreatedAt` 是新增可空响应字段,来自 `syb_product.syb_stock_created_at`,不等于 GoAuto 审计字段 `createdAt`。历史来源时间为空者在设置日期条件时不返回,不得回退到 `createdAt`;无日期条件仍可返回。
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
+2
View File
@@ -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"`
+11 -4
View File
@@ -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 &&
+12
View File
@@ -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 {
+5 -5
View File
@@ -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")
}
+29 -10
View File
@@ -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)
}
}
+27 -3
View File
@@ -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
+23 -1
View File
@@ -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
})
}
@@ -0,0 +1,43 @@
package version_local
import (
"testing"
"time"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigrateSYBStockCreatedAtPreservesLegacyRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:syb_stock_created_migration?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE TABLE syb_product (id integer primary key, created_at datetime)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO syb_product (id, created_at) VALUES (1, '2026-09-28 00:05:00')").Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err := migrateSYBStockCreatedAt(db, "test_syb_stock_created_at"); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasColumn(&models.SYBProduct{}, "SYBStockCreatedAt") || !db.Migrator().HasIndex(&models.SYBProduct{}, "SYBStockCreatedAt") {
t.Fatal("migration must add the source time column and index")
}
var row struct {
CreatedAt time.Time
SYBStockCreatedAt *string
}
if err := db.Table("syb_product").Where("id = 1").Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.CreatedAt.Format("2006-01-02 15:04:05") != "2026-09-28 00:05:00" || row.SYBStockCreatedAt != nil {
t.Fatalf("migration must preserve import time and leave source time unknown: %+v", row)
}
}