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:
@@ -13,6 +13,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&models.AgentDevice{},
|
||||
&models.PDDProduct{},
|
||||
&models.ShopeeProduct{},
|
||||
&models.SYBProduct{},
|
||||
&models.CollectionRule{},
|
||||
&models.CollectionTask{},
|
||||
&models.CollectionDimension{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ""))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user