merge(#358): fix SYB size-weight role recognition
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 682da37a8d52a2a886fbb86014eab3c44aa8ea1d
|
||||
synchronized_at: 2026-10-06T01:39:24Z
|
||||
wiki_revision: e0ac4bb20494a2659b8b6a4f8598884b56a94fa3
|
||||
synchronized_at: 2026-10-06T03:16:59Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -790,3 +790,14 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
|
||||
- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
|
||||
|
||||
## SYB 字母尺码与体重连写识别(#358)
|
||||
|
||||
实现绑定 `a8233ec`,2026-10-06 分支实现;尚未合并 main、发布或纠正生产数据。
|
||||
|
||||
- 明确尺码证据包含「字母尺码+可选空白+数字体重或范围+重量单位」,例如合成规格 `XL65-70kg`、`2XL 60.0-67.5公斤`;沿用既有字母尺码集合、重量单位(斤/公斤/千克/kg)和范围分隔符,整段匹配,不因任意 XL/L 前缀就判尺码。
|
||||
- 逗号两侧恰有一侧满足尺码证据时,该侧为尺码,支持颜色/尺码与尺码/颜色两种顺序;保留剥离既有【】备注后的完整尺码文本,不能只保留 XL。无逗号且整串是上述规格时判为仅尺码,不再错记为颜色。
|
||||
- 其他判定保持既有行为;两侧都具有尺码证据时 Parse 返回 uncertain,既有导入/重新解析按失败落库,不随意指定颜色。括号、斜杠、异常单位后缀不在本次新增语法内。
|
||||
- RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。
|
||||
- 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。
|
||||
- ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
func TestSizeWeight358ImportAndReparse(t *testing.T) {
|
||||
for _, mode := range []string{"reimport", "reparse", "manual", "ai"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
order := sybimport.OrderInput{Code: "SYNTHETIC-358", StockID: 358, ShopName: "测试店铺"}
|
||||
detail := sybimport.DetailInput{ID: 358, ProductID: 358, ProductQty: 1, ProductPrice: 100,
|
||||
ProductTitle: "合成测试商品", ProductSpec: "XL65-70kg,123深藍",
|
||||
Raw: json.RawMessage(`{"productSpec":"XL65-70kg,123深藍"}`)}
|
||||
applied, err := sybimport.ApplyDetail(ctx, db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := applied.SYBProduct.ID
|
||||
if applied.SYBProduct.TargetColor != "123深藍" || applied.SYBProduct.TargetSize != "XL65-70kg" {
|
||||
t.Fatal("new imports must have correct roles")
|
||||
}
|
||||
// Model the legacy reversed row plus an already-correct ERPGo value.
|
||||
mapping := &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{
|
||||
{Name: "XL65-70kg", Source: shopeeproduct.ValueSourceImport},
|
||||
}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{
|
||||
{Name: "123深藍", Source: shopeeproduct.ValueSourceImport},
|
||||
{Name: "XL65-70kg", Source: shopeeproduct.ValueSourceImport, Mapping: mapping},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).
|
||||
Update("specs_json", specs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"target_color": "XL65-70kg", "target_size": "123深藍",
|
||||
"manually_confirmed": mode == "manual", "ai_confirmed": mode == "ai",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "reimport" {
|
||||
_, err = sybimport.ApplyDetail(ctx, db, order, detail)
|
||||
} else {
|
||||
_, err = sybimport.ReparseBatch(ctx, db, []uint64{id}, false)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row models.SYBProduct
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.RawJSON != string(detail.Raw) {
|
||||
t.Fatal("original input was changed")
|
||||
}
|
||||
if mode == "manual" || mode == "ai" {
|
||||
if row.TargetColor != "XL65-70kg" || row.TargetSize != "123深藍" ||
|
||||
row.ManuallyConfirmed != (mode == "manual") || row.AIConfirmed != (mode == "ai") {
|
||||
t.Fatal("non-forced reparse overwrote confirmed input")
|
||||
}
|
||||
return
|
||||
}
|
||||
if row.TargetColor != "123深藍" || row.TargetSize != "XL65-70kg" || row.ParseStatus != models.SYBParseStatusSuccess {
|
||||
t.Fatal("legacy row was not corrected")
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.First(&product, *row.ShopeeProductID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dimensions, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preservedMapping, addedColor, retainedLegacy := false, false, false
|
||||
for _, dimension := range dimensions {
|
||||
for _, value := range dimension.Values {
|
||||
if dimension.Role == shopeeproduct.RoleSize && value.Name == "XL65-70kg" {
|
||||
preservedMapping = value.Mapping != nil && *value.Mapping == *mapping
|
||||
}
|
||||
if dimension.Role == shopeeproduct.RoleColor && value.Name == "123深藍" {
|
||||
addedColor = true
|
||||
}
|
||||
if dimension.Role == shopeeproduct.RoleColor && value.Name == "XL65-70kg" {
|
||||
retainedLegacy = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !preservedMapping || !addedColor || !retainedLegacy {
|
||||
t.Fatal("merge must preserve mappings and leave legacy cleanup to a separately authorized operation")
|
||||
}
|
||||
replay, err := sybimport.ReparseBatch(ctx, db, []uint64{id}, false)
|
||||
if err != nil || len(replay) != 1 || replay[0].Outcome != sybimport.ReparseOutcomeUnchanged {
|
||||
t.Fatalf("reparse not idempotent: %v %v", replay, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ var bracketPattern = regexp.MustCompile(`【[^】]*】`)
|
||||
// "size,color". A color dictionary would inevitably guess at product-specific
|
||||
// labels, so role reversal is allowed only when exactly one side matches this
|
||||
// deliberately narrow pattern.
|
||||
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
|
||||
// #358 also accepts an explicit letter size followed by a weight, optionally
|
||||
// separated by whitespace (XL65-70kg / 2XL 60.0-67.5公斤). Keep the full value:
|
||||
// neither an arbitrary letter prefix nor a weight without its unit is enough.
|
||||
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|(?:(?:x{0,4}[sml]|[2-9]xl)\s*)?\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
|
||||
|
||||
// splitOnWhitespace applies the comma rule to a spec that has no comma.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package sybspec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestSizeWeight358Parse(t *testing.T) {
|
||||
for _, size := range []string{
|
||||
"XL65-70kg", "2XL 60.0-67.5公斤", "S45kg", "M 50千克",
|
||||
"xxl120~140斤", "3XL70至75kg", "L55~60KG", "XL65到70公斤",
|
||||
} {
|
||||
for _, raw := range []string{size + ",123深藍", "123深藍," + size} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
got := Parse(raw)
|
||||
if got.Color != "123深藍" || got.Size != size || got.Status != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("unexpected parse: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{size, size + ",", "," + size} {
|
||||
got := Parse(raw)
|
||||
if got.Color != "" || got.Size != size || got.Status != models.SYBParseStatusSuccess {
|
||||
t.Errorf("single dimension %q: %+v", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeWeight358Boundaries(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"123深藍", "XL蓝色", "L型黑色", "MODEL65-70kg", "XL65-70",
|
||||
"XL65-70kg蓝色", "XL/65-70kg", "XL(65-70kg)", "XL65-70kgkg",
|
||||
"XL推薦65-70kg", "XL65-70cm",
|
||||
} {
|
||||
if explicitSizePattern.MatchString(value) {
|
||||
t.Errorf("non-size/out-of-scope value matched: %q", value)
|
||||
}
|
||||
got := Parse(value + ",M")
|
||||
if got.Color != value || got.Size != "M" || got.Status != models.SYBParseStatusSuccess {
|
||||
t.Errorf("existing descriptive color changed: %+v", got)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"XL65-70kg,M", "M,XL65-70kg", "L55-60kg,XL65-70kg"} {
|
||||
if got := Parse(raw); got.Status != models.SYBParseStatusUncertain {
|
||||
t.Errorf("two sizes must remain ambiguous: %+v", got)
|
||||
}
|
||||
}
|
||||
for _, size := range []string{"XL", "均碼", "65-70kg", "2XL 【推荐体重】"} {
|
||||
got := Parse(size + ",黑色【短袖】")
|
||||
if got.Color != "黑色" || got.Size != StripAnnotations(size) || got.Status != models.SYBParseStatusSuccess {
|
||||
t.Errorf("legacy size/annotation changed: %+v", got)
|
||||
}
|
||||
}
|
||||
if got := Parse("黑色 XL65-70kg"); got.Color != "黑色" || got.Size != "XL65-70kg" {
|
||||
t.Fatalf("whitespace-separated color/size: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeWeight358RawHalves(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"2XL 60.0-67.5公斤【说明】,黑色【短袖】",
|
||||
"黑色【短袖】,2XL 60.0-67.5公斤【说明】",
|
||||
} {
|
||||
color, size := RawSpecHalves(raw)
|
||||
if color != "黑色【短袖】" || size != "2XL 60.0-67.5公斤【说明】" {
|
||||
t.Errorf("raw roles reversed: %q / %q", color, size)
|
||||
}
|
||||
}
|
||||
color, size := RawSpecHalves("2XL 60.0-67.5公斤")
|
||||
if color != "" || size != "2XL 60.0-67.5公斤" {
|
||||
t.Fatalf("single size role: %q / %q", color, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeWeight358ResolveKeys(t *testing.T) {
|
||||
a := "XL65-70kg,黑色【短袖】"
|
||||
b := "黑色【長袖】,2XL 70-75公斤"
|
||||
stable := "白色【短袖】,M"
|
||||
got := ResolveKeys([]string{a, b, stable})
|
||||
if got[a].Color != "黑色【短袖】" || got[a].Size != "XL65-70kg" ||
|
||||
got[b].Color != "黑色【長袖】" || got[b].Size != "2XL 70-75公斤" {
|
||||
t.Fatalf("collision keys lost their roles: %+v", got)
|
||||
}
|
||||
if got[stable].Color != "白色" || got[stable].Size != "M" {
|
||||
t.Fatalf("unaffected key changed: %+v", got[stable])
|
||||
}
|
||||
for _, raw := range []string{"XL65-70kg,黑色", "黑色,XL65-70kg"} {
|
||||
key := ResolveKeys([]string{raw})[raw]
|
||||
if key.Color != "黑色" || key.Size != "XL65-70kg" {
|
||||
t.Errorf("noncolliding key: %+v", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user