From a8233ecee1251c0412b0736b6b5e345ffb8bbc7b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 11:15:47 +0800 Subject: [PATCH 1/2] fix(#358): recognize letter sizes with weight descriptions --- .../app/goauto/sybimport/size_weight_test.go | 110 ++++++++++++++++++ server/app/goauto/sybspec/parse.go | 5 +- server/app/goauto/sybspec/size_weight_test.go | 95 +++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 server/app/goauto/sybimport/size_weight_test.go create mode 100644 server/app/goauto/sybspec/size_weight_test.go diff --git a/server/app/goauto/sybimport/size_weight_test.go b/server/app/goauto/sybimport/size_weight_test.go new file mode 100644 index 0000000..8f3c34d --- /dev/null +++ b/server/app/goauto/sybimport/size_weight_test.go @@ -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) + } + }) + } +} diff --git a/server/app/goauto/sybspec/parse.go b/server/app/goauto/sybspec/parse.go index cfa8156..bd981e0 100644 --- a/server/app/goauto/sybspec/parse.go +++ b/server/app/goauto/sybspec/parse.go @@ -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. // diff --git a/server/app/goauto/sybspec/size_weight_test.go b/server/app/goauto/sybspec/size_weight_test.go new file mode 100644 index 0000000..d618208 --- /dev/null +++ b/server/app/goauto/sybspec/size_weight_test.go @@ -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) + } + } +} From fb3efc929bd58e4f1175e2a8be3a66464280ec43 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 6 Oct 2026 11:18:02 +0800 Subject: [PATCH 2/2] docs(#358): record size-weight parsing contract --- docs/03-business-rules-and-glossary.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index d26b51d..3eb50a4 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 @@ -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 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。