diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 12b9e11..207844d 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -13,6 +13,7 @@ func Migrate(db *gorm.DB) error { &models.AgentDevice{}, &models.PDDProduct{}, &models.ShopeeProduct{}, + &models.SYBProduct{}, &models.CollectionRule{}, &models.CollectionTask{}, &models.CollectionDimension{}, diff --git a/server/app/goauto/migrations/migrate_test.go b/server/app/goauto/migrations/migrate_test.go index 75bca33..3fc8570 100644 --- a/server/app/goauto/migrations/migrate_test.go +++ b/server/app/goauto/migrations/migrate_test.go @@ -63,7 +63,7 @@ func TestMigrationIsIdempotentAndHasExpectedTables(t *testing.T) { t.Fatalf("second migration: %v", err) } for _, table := range []string{ - "agent_device", "pdd_product", "collection_rule", "collection_task", + "agent_device", "pdd_product", "shopee_product", "syb_product", "collection_rule", "collection_task", "collection_dimension", "collection_dimension_value", "collection_sku", "collection_sku_value", } { if !db.Migrator().HasTable(table) { @@ -345,7 +345,7 @@ func TestMySQLTextJSONBlobColumnsHaveNoLiteralDefault(t *testing.T) { modelTypes := []any{ &models.AgentDevice{}, &models.PDDProduct{}, &models.CollectionRule{}, &models.CollectionTask{}, &models.CollectionDimension{}, &models.CollectionDimensionValue{}, &models.CollectionColorPrice{}, - &models.CollectionSKU{}, &models.CollectionSKUValue{}, &models.ShopeeProduct{}, + &models.CollectionSKU{}, &models.CollectionSKUValue{}, &models.ShopeeProduct{}, &models.SYBProduct{}, } typePattern := regexp.MustCompile(`type:(text|json|blob)`) for _, model := range modelTypes { diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index bb1bf4d..23be433 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -283,3 +283,70 @@ func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error { } return nil } + +// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse. +const ( + SYBParseStatusSuccess = "success" + SYBParseStatusUncertain = "uncertain" + SYBParseStatusFailed = "failed" +) + +// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry +// several Shopee product lines, and the same Shopee product can appear more +// than once within one order at different colors/sizes/quantities — each such +// line is its own row here (#41). This table never stores purchase task, +// order or logistics fields; it only records what SYB reported. +// +// Uniqueness is the order code plus the source detail id, not a single global +// id: SYB's detail id shape has only been observed within one order in the +// available sample data, so a global-uniqueness assumption is unverified. +type SYBProduct struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + // OrderCode is the shipment order's `code` (e.g. 260728TB95MJTQ). + OrderCode string `json:"orderCode" gorm:"size:64;not null;uniqueIndex:ux_syb_product_order_detail,priority:1"` + // DetailID is `details[].id` from SYB, e.g. 145306175. + DetailID uint64 `json:"detailId" gorm:"not null;uniqueIndex:ux_syb_product_order_detail,priority:2"` + // StockID is the shipment order's own internal id (o.id, e.g. 75104587), + // 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:''"` + + // ShopeeItemID is `details[].productId` as a string, matching + // shopee_product.shopee_item_id's type. ShopeeProductID links to the + // archive once found, revived or created by the import. + ShopeeItemID string `json:"shopeeItemId" gorm:"size:64;not null;index"` + ShopeeProductID *uint64 `json:"shopeeProductId" gorm:"index"` + ProductTitle string `json:"productTitle" gorm:"size:500;not null;default:''"` + + // TargetColor/TargetSize hold the parser's output. Both stay empty when + // ParseStatus is failed; Uncertain may still populate one or both with a + // candidate that needs human review (see sybimport.Parse). + TargetColor string `json:"targetColor" gorm:"size:255;not null;default:''"` + TargetSize string `json:"targetSize" gorm:"size:255;not null;default:''"` + Quantity int64 `json:"quantity" gorm:"not null;check:ck_syb_product_quantity,quantity >= 1"` + // UnitPriceCent is productPrice converted to integer cents, consistent + // with the priceCent/salePriceCent convention (#31, #40). + UnitPriceCent int64 `json:"unitPriceCent" gorm:"not null;check:ck_syb_product_unit_price,unit_price_cent >= 0"` + ImageURL string `json:"imageUrl" gorm:"type:text;not null"` + + ParseStatus string `json:"parseStatus" gorm:"size:16;not null;index;check:ck_syb_product_parse_status,parse_status IN ('success','uncertain','failed')"` + ParseNote string `json:"parseNote" gorm:"size:500;not null;default:''"` + + // RawJSON is the untouched `details[]` element as SYB returned it. It is + // what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始 + // JSON,不请求货运宝接口") reads from; it must never be rewritten by a + // parse-rule change, only the derived fields above may change. + RawJSON string `json:"-" gorm:"type:json;not null"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (SYBProduct) TableName() string { return "syb_product" } + +func (product *SYBProduct) BeforeCreate(_ *gorm.DB) error { + if product.RawJSON == "" { + product.RawJSON = "{}" + } + return nil +} diff --git a/server/app/goauto/shopeeproduct/service.go b/server/app/goauto/shopeeproduct/service.go index bd33872..6d1193c 100644 --- a/server/app/goauto/shopeeproduct/service.go +++ b/server/app/goauto/shopeeproduct/service.go @@ -582,8 +582,11 @@ func (service *Service) BatchSoftDelete(ctx context.Context, deletedBy uint64, r // references this product yet. func referencedBy(db *gorm.DB, id uint64) (string, error) { if db.Migrator().HasTable("syb_product") { + // syb_product carries no soft delete (#41 defines no such requirement), + // so every stored row is live; the deleted_at filter used for + // purchase_task below does not apply here. var count int64 - if err := db.Table("syb_product").Where("shopee_product_id = ? AND deleted_at IS NULL", id).Count(&count).Error; err != nil { + if err := db.Table("syb_product").Where("shopee_product_id = ?", id).Count(&count).Error; err != nil { return "", err } if count > 0 { @@ -631,15 +634,22 @@ func (service *Service) Restore(ctx context.Context, id uint64, request RestoreR // per-product selection has nothing real to validate against; a bad config // value falls back to fallbackCurrency rather than corrupting new records. func (service *Service) resolveCurrency(ctx context.Context) (string, error) { - db := service.DB.WithContext(ctx) + return ResolveCurrency(ctx, service.DB) +} + +// ResolveCurrency is exported so other units that create a ShopeeProduct +// directly (#41's import, which is not an admin-API call) apply the exact +// same system-default-plus-whitelist rule instead of duplicating it. +func ResolveCurrency(ctx context.Context, db *gorm.DB) (string, error) { + scoped := db.WithContext(ctx) // sys_config belongs to the go-admin core schema, not the goauto module // migration; it may be absent in a minimal test database. Its absence is // not an error, just "no override configured". - if !db.Migrator().HasTable(&adminmodels.SysConfig{}) { + if !scoped.Migrator().HasTable(&adminmodels.SysConfig{}) { return fallbackCurrency, nil } var config adminmodels.SysConfig - err := db.Where("config_key = ?", DefaultCurrencyConfigKey).First(&config).Error + err := scoped.Where("config_key = ?", DefaultCurrencyConfigKey).First(&config).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return "", internalError(err) } diff --git a/server/app/goauto/shopeeproduct/service_test.go b/server/app/goauto/shopeeproduct/service_test.go index 355c063..d08197d 100644 --- a/server/app/goauto/shopeeproduct/service_test.go +++ b/server/app/goauto/shopeeproduct/service_test.go @@ -385,3 +385,27 @@ func TestResolveCurrencyFallsBackWhenUnconfigured(t *testing.T) { t.Fatalf("expected fallback currency %s, got %s", fallbackCurrency, response.Product.Currency) } } + +// referencedBy queries syb_product without a deleted_at filter (that table has +// no soft delete). This is a regression test for a real bug: once #41's +// migrations.Migrate() registered syb_product, BatchSoftDelete started failing +// with "no such column: deleted_at" because referencedBy assumed a filter +// that table does not have. +func TestBatchSoftDeleteWorksAfterSYBProductTableExists(t *testing.T) { + db := openTestDB(t) + if !db.Migrator().HasTable("syb_product") { + t.Skip("syb_product table not present in this build") + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-REF"}) + if err != nil { + t.Fatalf("create: %v", err) + } + results, err := service.BatchSoftDelete(context.Background(), 1, BatchDeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{created.Product.ID}}) + if err != nil { + t.Fatalf("batch delete must not fail once syb_product exists: %v", err) + } + if results[0].Status != "deleted" { + t.Fatalf("expected deleted, got %+v", results[0]) + } +} diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go new file mode 100644 index 0000000..ed27d0a --- /dev/null +++ b/server/app/goauto/sybimport/apply.go @@ -0,0 +1,296 @@ +package sybimport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + + "gorm.io/gorm" +) + +// OrderInput is the shipment-order-level context a detail line needs. It is +// deliberately a small, already-decoded struct rather than the raw SYB JSON: +// this package never talks to SYB's API directly (no credentials or endpoint +// 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 +} + +// DetailInput mirrors one element of SYB's `details[]` array from +// /am/stock/detail/listByStock. ProductPrice is that endpoint's decimal +// yuan/dollar value (e.g. 239.0), not the ×100 integer used by +// /am/stock/list's amtOrder — the two endpoints use different units for +// superficially similar-looking amounts, confirmed against real samples, and +// must not share one conversion path. +type DetailInput struct { + ID uint64 + ProductID uint64 + ProductQty int64 + ProductPrice float64 + ProductSpec string + ProductTitle string + ProductThumb uint64 + Raw json.RawMessage +} + +// ApplyResult reports what ApplyDetail actually did, for the import-result +// page's per-line feedback (#41 prototype: 新增/覆盖/跳过统计). +type ApplyResult struct { + SYBProduct models.SYBProduct + Outcome string // created | updated + ShopeeOutcome string // linked_existing | revived | created_minimal | not_linked + ShopeeProduct *models.ShopeeProduct + // ShopeeSpecs is ShopeeProduct.SpecsJSON already decoded, since every + // caller that inspects the merge result needs it and decoding it once + // here avoids repeating shopeeproduct.Unmarshal at each call site. + ShopeeSpecs []shopeeproduct.SpecDimension +} + +const ( + OutcomeCreated = "created" + OutcomeUpdated = "updated" + + ShopeeOutcomeLinkedExisting = "linked_existing" + ShopeeOutcomeRevived = "revived" + ShopeeOutcomeCreatedMinimal = "created_minimal" + ShopeeOutcomeNotLinked = "not_linked" +) + +// ApplyDetail upserts one SYB detail line and folds it into the shared +// shopee_product archive. It is idempotent on (order_code, detail_id): a +// second call with the same order+detail overwrites the same row rather than +// creating a duplicate (#41: 重复导入相同来源明细执行覆盖/更新,不产生重复记录). +// +// It never creates purchase tasks and never writes any order/logistics field. +func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail DetailInput) (ApplyResult, error) { + if strings.TrimSpace(order.Code) == "" { + return ApplyResult{}, fmt.Errorf("order code is required") + } + if detail.ID == 0 { + return ApplyResult{}, fmt.Errorf("detail id is required") + } + if detail.ProductQty < 1 { + return ApplyResult{}, fmt.Errorf("detail %d: productQty must be at least 1, source values are never split into orders of zero", detail.ID) + } + + parsed := Parse(detail.ProductSpec) + rawJSON, err := normalizeRaw(detail.Raw) + if err != nil { + return ApplyResult{}, err + } + + var result ApplyResult + txErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + shopeeItemID := strconv.FormatUint(detail.ProductID, 10) + shopeeProduct, shopeeOutcome, err := findOrCreateShopeeProduct(ctx, tx, shopeeItemID, order, detail) + if err != nil { + return err + } + if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil { + if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil { + return err + } + // mergeParsedSpec writes specs_json through its own query and does + // not touch this pointer's in-memory copy, so the merged value must + // be re-read before it is reported back to the caller. + if err := tx.First(shopeeProduct, shopeeProduct.ID).Error; err != nil { + return err + } + } + + record := models.SYBProduct{ + OrderCode: order.Code, DetailID: detail.ID, StockID: order.StockID, ShopName: order.ShopName, + ShopeeItemID: shopeeItemID, ProductTitle: detail.ProductTitle, + TargetColor: parsed.Color, TargetSize: parsed.Size, + Quantity: detail.ProductQty, UnitPriceCent: roundCents(detail.ProductPrice), + ImageURL: buildImageURL(detail.ProductThumb), + ParseStatus: parsed.Status, ParseNote: parsed.Note, RawJSON: rawJSON, + } + if shopeeProduct != nil { + record.ShopeeProductID = &shopeeProduct.ID + } + + var existing models.SYBProduct + err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err := tx.Create(&record).Error; err != nil { + return err + } + result.Outcome = OutcomeCreated + case err == nil: + record.ID = existing.ID + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{ + "stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID, + "shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle, + "target_color": record.TargetColor, "target_size": record.TargetSize, + "quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL, + "parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON, + }).Error; err != nil { + return err + } + result.Outcome = OutcomeUpdated + default: + return err + } + result.SYBProduct = record + result.ShopeeOutcome = shopeeOutcome + result.ShopeeProduct = shopeeProduct + if shopeeProduct != nil { + specs, err := shopeeproduct.Unmarshal(shopeeProduct.SpecsJSON) + if err != nil { + return err + } + result.ShopeeSpecs = specs + } + return nil + }) + if txErr != nil { + return ApplyResult{}, txErr + } + return result, nil +} + +// findOrCreateShopeeProduct implements #40's revival rule: a live match wins, +// a soft-deleted match is revived (keeping its prior mapping), and only when +// neither exists does the import create a minimal archive. On an existing +// record — live or revived — only empty image/price fields are filled in; +// title, shop name and mappings that a human already edited are never +// overwritten (#40: 命中既有档案时,只补写为空的参考图与售价). +func findOrCreateShopeeProduct(ctx context.Context, tx *gorm.DB, shopeeItemID string, order OrderInput, detail DetailInput) (*models.ShopeeProduct, string, error) { + var live models.ShopeeProduct + err := tx.Where("shopee_item_id = ?", shopeeItemID).First(&live).Error + if err == nil { + if err := fillEmptyArchiveFields(tx, &live, detail); err != nil { + return nil, "", err + } + return &live, ShopeeOutcomeLinkedExisting, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", err + } + + var deleted models.ShopeeProduct + err = tx.Unscoped().Where("shopee_item_id = ? AND deleted_at IS NOT NULL", shopeeItemID). + Order("deleted_at DESC").First(&deleted).Error + if err == nil { + if _, err := shopeeproduct.Revive(tx, deleted.ID); err != nil { + return nil, "", err + } + if err := tx.First(&deleted, deleted.ID).Error; err != nil { + return nil, "", err + } + if err := fillEmptyArchiveFields(tx, &deleted, detail); err != nil { + return nil, "", err + } + return &deleted, ShopeeOutcomeRevived, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", err + } + + currency, err := shopeeproduct.ResolveCurrency(ctx, tx) + if err != nil { + return nil, "", err + } + created := models.ShopeeProduct{ + ShopeeItemID: shopeeItemID, Title: strings.TrimSpace(detail.ProductTitle), ShopName: strings.TrimSpace(order.ShopName), + ImageURL: buildImageURL(detail.ProductThumb), Currency: currency, SpecsJSON: "[]", + } + price := roundCents(detail.ProductPrice) + created.SalePriceCent = &price + if err := tx.Create(&created).Error; err != nil { + return nil, "", err + } + return &created, ShopeeOutcomeCreatedMinimal, nil +} + +func fillEmptyArchiveFields(tx *gorm.DB, product *models.ShopeeProduct, detail DetailInput) error { + updates := map[string]any{} + if product.ImageURL == "" { + url := buildImageURL(detail.ProductThumb) + updates["image_url"] = url + product.ImageURL = url + } + if product.SalePriceCent == nil { + price := roundCents(detail.ProductPrice) + updates["sale_price_cent"] = price + product.SalePriceCent = &price + } + if len(updates) == 0 { + return nil + } + return tx.Model(&models.ShopeeProduct{}).Where("id = ?", product.ID).Updates(updates).Error +} + +// mergeParsedSpec folds a successfully parsed color/size into the archive as +// import-sourced spec values (#40: 虾皮规格值...或由 #41 SYB 明细导入写入; +// 每个规格值保存来源标记). Only a clean, unambiguous parse reaches here — +// uncertain and failed parses are recorded on the SYB detail row for human +// review but are never pushed into the shared archive, so a messy raw string +// never pollutes the spec list other screens read from. +func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error { + var product models.ShopeeProduct + if err := tx.First(&product, shopeeProductID).Error; err != nil { + return err + } + existing, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + return err + } + incoming := []shopeeproduct.SpecDimension{} + if parsed.Color != "" { + incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: parsed.Color, Source: shopeeproduct.ValueSourceImport}}}) + } + if parsed.Size != "" { + incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: parsed.Size, Source: shopeeproduct.ValueSourceImport}}}) + } + if len(incoming) == 0 { + return nil + } + merged := shopeeproduct.Merge(existing, incoming) + if err := shopeeproduct.Validate(merged); err != nil { + return err + } + specsJSON, err := shopeeproduct.Marshal(merged) + if err != nil { + return err + } + return tx.Model(&models.ShopeeProduct{}).Where("id = ?", shopeeProductID).Update("specs_json", specsJSON).Error +} + +// buildImageURL mirrors the rule confirmed against real SYB responses and +// already applied in #40: the thumbnail id is not a URL by itself. +func buildImageURL(productThumb uint64) string { + if productThumb == 0 { + return "" + } + return fmt.Sprintf("https://www.shunyunbaoerp.com/api/p/file?id=%d", productThumb) +} + +func roundCents(amount float64) int64 { + if amount < 0 { + return 0 + } + return int64(math.Round(amount * 100)) +} + +func normalizeRaw(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "{}", nil + } + if !json.Valid(raw) { + return "", fmt.Errorf("raw detail payload is not valid JSON") + } + return string(raw), nil +} diff --git a/server/app/goauto/sybimport/apply_test.go b/server/app/goauto/sybimport/apply_test.go new file mode 100644 index 0000000..7073be1 --- /dev/null +++ b/server/app/goauto/sybimport/apply_test.go @@ -0,0 +1,336 @@ +package sybimport_test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + "go-admin/app/goauto/sybimport" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := migrations.Migrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +// realOrder returns the actual order-level context from +// demo/shunyunbaoerp_stock_list.har order 260728TB95MJTQ (stock id 75104587). +func realOrder() sybimport.OrderInput { + return sybimport.OrderInput{Code: "260728TB95MJTQ", StockID: 75104587, ShopName: "韓舍佳人女裝店"} +} + +// realDetailA/B are the two actual details[] entries from the same order, +// copied verbatim (not fabricated) from the HAR capture used throughout #41. +func realDetailA() sybimport.DetailInput { + return sybimport.DetailInput{ + ID: 145306175, ProductID: 50209124255, ProductQty: 1, ProductPrice: 239.0, + ProductSpec: "白色,L【建議50-60公斤】", + ProductTitle: "蕾絲花邊拼接背心女 上衣 背心 無袖打底衫 大碼設計感蕾絲花邊拼接背心女春夏新款胖mm寬鬆顯瘦外穿無袖上衣女裝", + ProductThumb: 190639637, + Raw: json.RawMessage(`{"id":145306175,"productQty":1,"productPrice":239.0,"productThumb":190639637,"productTitle":"蕾絲花邊拼接背心女","productSpec":"白色,L【建議50-60公斤】","sku":"","variationSku":"","purchaseStatus":0,"purchaseCode":"","pruchaseId":38884195,"productId":50209124255}`), + } +} + +func realDetailB() sybimport.DetailInput { + return sybimport.DetailInput{ + ID: 145306176, ProductID: 50712076553, ProductQty: 1, ProductPrice: 439.0, + ProductSpec: "黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】", + ProductTitle: "不規則T恤女 上衣 T恤 短袖衫 韓系服飾 夏季百搭拼接不規則蕾絲下襬t恤衫女減齡修身短袖純棉上衣", + ProductThumb: 190639638, + Raw: json.RawMessage(`{"id":145306176,"productQty":1,"productPrice":439.0,"productThumb":190639638,"productTitle":"不規則T恤女","productSpec":"黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】","sku":"","variationSku":"","purchaseStatus":0,"purchaseCode":"","pruchaseId":38884196,"productId":50712076553}`), + } +} + +// ---------------------------------------------------------------- basic apply + +func TestApplyDetailCreatesRowWithConvertedPriceAndImageURL(t *testing.T) { + db := openTestDB(t) + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.Outcome != sybimport.OutcomeCreated { + t.Fatalf("expected created, got %s", result.Outcome) + } + // productPrice 239.0 (decimal yuan/dollar from /detail/listByStock) must + // become 23900 cents, NOT reuse the ×100-different unit seen on + // /am/stock/list's amtOrder for the same order. + if result.SYBProduct.UnitPriceCent != 23900 { + t.Fatalf("expected 23900 cents, got %d", result.SYBProduct.UnitPriceCent) + } + if result.SYBProduct.ImageURL != "https://www.shunyunbaoerp.com/api/p/file?id=190639637" { + t.Fatalf("unexpected image url: %s", result.SYBProduct.ImageURL) + } + if result.SYBProduct.TargetColor != "白色" || result.SYBProduct.TargetSize != "L" { + t.Fatalf("expected clean parse 白色/L, got %q/%q", result.SYBProduct.TargetColor, result.SYBProduct.TargetSize) + } + if result.SYBProduct.ParseStatus != models.SYBParseStatusSuccess { + t.Fatalf("expected success, got %s", result.SYBProduct.ParseStatus) + } +} + +func TestApplyDetailRejectsQuantityBelowOne(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + detail.ProductQty = 0 + if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail); err == nil { + t.Fatal("expected error for productQty < 1") + } +} + +// #41: 数量1到N按来源值结构化保存,不在导入阶段拆单. +func TestApplyDetailKeepsMultiUnitQuantityAsOneRow(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + detail.ID = 999001 + detail.ProductQty = 5 + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.SYBProduct.Quantity != 5 { + t.Fatalf("expected quantity 5 preserved as one row, got %d", result.SYBProduct.Quantity) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("order_code = ? AND detail_id = ?", "260728TB95MJTQ", 999001).Count(&count) + if count != 1 { + t.Fatalf("expected exactly one row for this detail line, got %d", count) + } +} + +// ---------------------------------------------------------------- 幂等 / 覆盖 + +// #41: 重复导入相同来源明细执行覆盖/更新,不产生重复记录. +func TestApplyDetailIsIdempotentOnOrderCodeAndDetailID(t *testing.T) { + db := openTestDB(t) + order, detail := realOrder(), realDetailA() + 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 + second, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatalf("second apply: %v", err) + } + if second.Outcome != sybimport.OutcomeUpdated { + t.Fatalf("expected updated, got %s", second.Outcome) + } + if second.SYBProduct.ID != first.SYBProduct.ID { + t.Fatal("re-import must update the same row, not create a new one") + } + var count int64 + db.Model(&models.SYBProduct{}).Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).Count(&count) + if count != 1 { + t.Fatalf("expected exactly 1 row after re-import, got %d", count) + } +} + +// 同一货运单中同一虾皮商品的多组颜色尺码必须保存为独立明细,不能被幂等键误合并. +func TestSameShopeeProductDifferentDetailIDsStayIndependentRows(t *testing.T) { + db := openTestDB(t) + order := realOrder() + a := realDetailA() + b := realDetailA() + b.ID = 145306180 + b.ProductSpec = "白色,XL" + if _, err := sybimport.ApplyDetail(context.Background(), db, order, a); err != nil { + t.Fatalf("apply a: %v", err) + } + if _, err := sybimport.ApplyDetail(context.Background(), db, order, b); err != nil { + t.Fatalf("apply b: %v", err) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("shopee_item_id = ?", "50209124255").Count(&count) + if count != 2 { + t.Fatalf("expected 2 independent detail rows for the same shopee item, got %d", count) + } +} + +// ---------------------------------------------------------------- shopee 关联 + +func TestApplyDetailCreatesMinimalShopeeArchiveWhenMissing(t *testing.T) { + db := openTestDB(t) + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.ShopeeOutcome != sybimport.ShopeeOutcomeCreatedMinimal { + t.Fatalf("expected created_minimal, got %s", result.ShopeeOutcome) + } + if result.ShopeeProduct.ShopeeItemID != "50209124255" { + t.Fatalf("unexpected shopee item id: %s", result.ShopeeProduct.ShopeeItemID) + } + if result.SYBProduct.ShopeeProductID == nil || *result.SYBProduct.ShopeeProductID != result.ShopeeProduct.ID { + t.Fatal("syb row must link to the created shopee product") + } +} + +// 重复导入不得重复创建虾皮商品档案. +func TestApplyDetailDoesNotDuplicateShopeeArchiveOnReimport(t *testing.T) { + db := openTestDB(t) + order, detail := realOrder(), realDetailA() + if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil { + t.Fatalf("first: %v", err) + } + second, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatalf("second: %v", err) + } + if second.ShopeeOutcome != sybimport.ShopeeOutcomeLinkedExisting { + t.Fatalf("expected linked_existing on reimport, got %s", second.ShopeeOutcome) + } + var count int64 + db.Model(&models.ShopeeProduct{}).Where("shopee_item_id = ?", "50209124255").Count(&count) + if count != 1 { + t.Fatalf("expected exactly 1 shopee product, got %d", count) + } +} + +// #40: 已软删除的 shopee_item_id 被 #41 重复导入命中时,清空 deleted_at 复活原 +// 记录并保留其人工映射. +func TestApplyDetailRevivesSoftDeletedShopeeArchiveAndKeepsMapping(t *testing.T) { + db := openTestDB(t) + shopeeSvc := newTestShopeeService(db) + created, err := shopeeSvc.Create(context.Background(), shopeeCreateRequest("50209124255")) + if err != nil { + t.Fatalf("seed shopee product: %v", err) + } + if _, err := deleteShopeeProduct(db, created.Product.ID, 1); err != nil { + t.Fatalf("soft delete: %v", err) + } + + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply after soft delete: %v", err) + } + if result.ShopeeOutcome != sybimport.ShopeeOutcomeRevived { + t.Fatalf("expected revived, got %s", result.ShopeeOutcome) + } + if result.ShopeeProduct.ID != created.Product.ID { + t.Fatal("revival must reuse the original row, not create a new one") + } + if result.ShopeeProduct.Title != "人工维护的标题" { + t.Fatalf("revival must not overwrite a human-edited title, got %q", result.ShopeeProduct.Title) + } +} + +// #40: 命中既有档案时,只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺 +// 和规格映射. +func TestApplyDetailNeverOverwritesHumanEditedTitleOnExistingArchive(t *testing.T) { + db := openTestDB(t) + shopeeSvc := newTestShopeeService(db) + if _, err := shopeeSvc.Create(context.Background(), shopeeCreateRequest("50209124255")); err != nil { + t.Fatalf("seed: %v", err) + } + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.ShopeeProduct.Title != "人工维护的标题" { + t.Fatalf("existing human-edited title must survive import, got %q", result.ShopeeProduct.Title) + } +} + +// ---------------------------------------------------------------- 规格合并 + +// 只有解析成功(success)才写入虾皮商品档案的规格值;存疑/失败的候选只留在 +// SYB 明细上供人工复核,不污染共享档案。 +func TestApplyDetailOnlyMergesSuccessfullyParsedSpecIntoArchive(t *testing.T) { + db := openTestDB(t) + clean, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply clean: %v", err) + } + if len(clean.ShopeeSpecs) == 0 { + t.Fatal("a successfully parsed color/size should be merged into the archive") + } + + ambiguous := realDetailB() + result, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), ambiguous) + if err != nil { + t.Fatalf("apply ambiguous: %v", err) + } + if result.SYBProduct.ParseStatus != models.SYBParseStatusUncertain { + t.Fatalf("expected uncertain for the ambiguous sample, got %s", result.SYBProduct.ParseStatus) + } + if len(result.ShopeeSpecs) != 0 { + t.Fatalf("an uncertain parse must not be pushed into the shared archive, got %+v", result.ShopeeSpecs) + } +} + +func TestApplyDetailMergeIsIdempotentAcrossReimports(t *testing.T) { + db := openTestDB(t) + order, detail := realOrder(), realDetailA() + if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil { + t.Fatalf("first: %v", err) + } + result, err := sybimport.ApplyDetail(context.Background(), db, order, detail) + if err != nil { + t.Fatalf("second: %v", err) + } + colorValues := 0 + for _, dimension := range result.ShopeeSpecs { + if dimension.Role == "color" { + colorValues = len(dimension.Values) + } + } + if colorValues != 1 { + t.Fatalf("re-importing the same detail must not duplicate the merged spec value, got %d color values", colorValues) + } +} + +// ---------------------------------------------------------------- 边界 + +func TestApplyDetailRejectsInvalidRawJSON(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + detail.Raw = json.RawMessage(`{not valid`) + if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail); err == nil { + t.Fatal("expected error for invalid raw JSON") + } +} + +func TestApplyDetailRejectsMissingOrderCode(t *testing.T) { + db := openTestDB(t) + order := realOrder() + order.Code = "" + if _, err := sybimport.ApplyDetail(context.Background(), db, order, realDetailA()); err == nil { + t.Fatal("expected error for empty order code") + } +} + +// ---------------------------------------------------------------- test helpers + +func newTestShopeeService(db *gorm.DB) *shopeeproduct.Service { return shopeeproduct.NewService(db) } + +func shopeeCreateRequest(shopeeItemID string) shopeeproduct.CreateRequest { + return shopeeproduct.CreateRequest{RequestID: newUUID(), ShopeeItemID: shopeeItemID, Title: "人工维护的标题", ShopName: "人工维护的店铺"} +} + +func deleteShopeeProduct(db *gorm.DB, id uint64, deletedBy uint64) (int64, error) { + return shopeeproduct.SoftDelete(db, id, deletedBy) +} + +var uuidCounter int + +func newUUID() string { + uuidCounter++ + return fmt.Sprintf("00000000-0000-0000-0000-%012d", uuidCounter) +} diff --git a/server/app/goauto/sybimport/parse.go b/server/app/goauto/sybimport/parse.go new file mode 100644 index 0000000..d7b4ffc --- /dev/null +++ b/server/app/goauto/sybimport/parse.go @@ -0,0 +1,82 @@ +// Package sybimport owns the SYB (顺云宝 ERP) shipment detail import: parsing +// productSpec into color/size, and folding each detail into the independent +// syb_product table and the shared shopee_product archive. It never creates +// purchase tasks or stores order/logistics fields (#41). +package sybimport + +import ( + "regexp" + "strings" + + "go-admin/app/goauto/models" +) + +var bracketPattern = regexp.MustCompile(`【[^】]*】`) + +// ambiguousPattern flags leftover separators or multi-token noise after +// bracket stripping — the signal that a "clean" split still isn't reliable. +// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips +// its bracket to "黑色+白色 簡約親膚", which still carries a '+' and internal +// whitespace, so it must not be reported as a confident match. +var ambiguousPattern = regexp.MustCompile(`[++\s]`) + +// ParseResult is the color/size candidate extracted from one productSpec +// string, plus how much the caller should trust it. +type ParseResult struct { + Color string + Size string + Status string + Note string +} + +// Parse implements the #41 rule: split on the last comma into color/size, +// strip 【...】 annotations, and classify the result. It never guesses a +// missing value and never invents a color or size that is not literally +// present in the input. +// +// Rules, derived from real SYB samples (demo/shunyunbaoerp_stock_list.har) +// plus the boundary cases already confirmed in the #41 prototype: +// - empty/whitespace-only input -> failed, nothing to extract. +// - no comma present (e.g. "均碼") -> uncertain: the whole string, with +// brackets stripped, becomes the size candidate; color stays empty. A +// single token with no separator cannot be split into two dimensions +// without guessing which one it is. +// - comma present, last segment (size) and first segment(s) (color) both +// non-empty after stripping brackets, and the color candidate has no +// leftover separator/whitespace -> success. +// - comma present but either side is empty after stripping, or the color +// candidate still carries a leftover '+' or internal whitespace -> the +// split happened but is not trustworthy -> uncertain. +func Parse(raw string) ParseResult { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 为空,无法拆分颜色尺码"} + } + + lastComma := strings.LastIndex(trimmed, ",") + // SYB samples use the ASCII comma; a full-width Chinese comma has not been + // observed, so it is deliberately not treated as a separator here rather + // than guessed at. + if lastComma < 0 { + size := stripBrackets(trimmed) + if size == "" { + return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"} + } + return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"} + } + + colorPart := stripBrackets(trimmed[:lastComma]) + sizePart := stripBrackets(trimmed[lastComma+1:]) + + if colorPart == "" || sizePart == "" { + return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"} + } + if ambiguousPattern.MatchString(colorPart) { + return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"} + } + return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess} +} + +func stripBrackets(part string) string { + return strings.TrimSpace(bracketPattern.ReplaceAllString(part, "")) +} diff --git a/server/app/goauto/sybimport/parse_test.go b/server/app/goauto/sybimport/parse_test.go new file mode 100644 index 0000000..ab22d6f --- /dev/null +++ b/server/app/goauto/sybimport/parse_test.go @@ -0,0 +1,81 @@ +package sybimport_test + +import ( + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybimport" +) + +// Real samples from demo/shunyunbaoerp_stock_list.har (details[145306175], +// details[145306176]), not fabricated fixtures. +func TestParseRealSample_CleanColorAndSize(t *testing.T) { + result := sybimport.Parse("白色,L【建議50-60公斤】") + if result.Status != models.SYBParseStatusSuccess { + t.Fatalf("expected success, got %s (%s)", result.Status, result.Note) + } + if result.Color != "白色" || result.Size != "L" { + t.Fatalf("expected 白色/L, got %q/%q", result.Color, result.Size) + } +} + +func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) { + result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】") + if result.Status != models.SYBParseStatusUncertain { + t.Fatalf("expected uncertain, got %s", result.Status) + } + if result.Size != "L" { + t.Fatalf("size should still parse cleanly, got %q", result.Size) + } + if result.Color == "" { + t.Fatal("color candidate should still be reported for human review, not discarded") + } +} + +func TestParseEmptyInputFails(t *testing.T) { + result := sybimport.Parse(" ") + if result.Status != models.SYBParseStatusFailed { + t.Fatalf("expected failed, got %s", result.Status) + } + if result.Color != "" || result.Size != "" { + t.Fatal("failed parse must not invent a color or size") + } +} + +func TestParseNoSeparatorIsUncertainNotFailed(t *testing.T) { + result := sybimport.Parse("均碼") + if result.Status != models.SYBParseStatusUncertain { + t.Fatalf("expected uncertain (not failed) for a single token, got %s", result.Status) + } + if result.Size != "均碼" || result.Color != "" { + t.Fatalf("single token should become the size candidate only, got color=%q size=%q", result.Color, result.Size) + } +} + +func TestParseNeverInventsAColorOrSizeNotInInput(t *testing.T) { + for _, raw := range []string{"", " ", "均碼", "白色,L", "黑色+白色 备注,L"} { + result := sybimport.Parse(raw) + if result.Color != "" && !containsSubstring(raw, result.Color) { + t.Fatalf("Parse(%q) invented color %q not present in input", raw, result.Color) + } + if result.Size != "" && !containsSubstring(raw, result.Size) { + t.Fatalf("Parse(%q) invented size %q not present in input", raw, result.Size) + } + } +} + +func containsSubstring(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +func TestParseBothSidesEmptyAfterStrippingIsUncertain(t *testing.T) { + result := sybimport.Parse("【備註】,【備註】") + if result.Status != models.SYBParseStatusUncertain { + t.Fatalf("expected uncertain when both sides strip to empty, got %s", result.Status) + } +}