feat(#41): add syb_product model, productSpec parser and shopee import merge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-19 14:31:38 +08:00
co-authored by Claude Opus 5
parent 45e8c1285d
commit d67629808f
9 changed files with 903 additions and 6 deletions
+1
View File
@@ -13,6 +13,7 @@ func Migrate(db *gorm.DB) error {
&models.AgentDevice{},
&models.PDDProduct{},
&models.ShopeeProduct{},
&models.SYBProduct{},
&models.CollectionRule{},
&models.CollectionTask{},
&models.CollectionDimension{},
+2 -2
View File
@@ -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 {
+67
View File
@@ -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
}
+14 -4
View File
@@ -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)
}
@@ -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])
}
}
+296
View File
@@ -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
}
+336
View File
@@ -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)
}
+82
View File
@@ -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, ""))
}
+81
View File
@@ -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)
}
}