Files
goauto/server/app/goauto/sybspec/parse.go
T

172 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sybspec owns the productSpec parsing primitives shared by the SYB
// import and the purchase readiness check.
//
// `[必须]` 它必须是叶子包。拆分、剥离、角色反转这三件事既决定了 target_color/
// target_size,也决定了哪些原始规格会塔缩到同一个键(#289)。两边各写一份
// 镜像实现必然漂移,到时塔缩检测会拿错半边去比,结论反而不可信。
//
// 原属 sybimport;purchase 需要它而 sybimport 已依赖 purchase,直接引用会成环,
// 因此下沉。sybimport 保留别名转发,调用方无需改动。
package sybspec
import (
"regexp"
"strings"
"go-admin/app/goauto/models"
)
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
// explicitSizePattern recognizes only values whose spelling carries a strong
// size signal. SYB has now been observed returning both "color,size" and
// "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.
// #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.
//
// `[必须]` #274 removed the old ambiguity guard so that descriptive colors
// like "黑色+白色 簡約親膚" are accepted instead of rejected. That is right, but
// it also let "黑色 XL" through as one confident **color**, dropping the size and
// marking the row success — and success rows never reach the AI parse queue
// (ai_parse_batch.go selects parse_status IN ('uncertain','failed')), so nothing
// ever corrects them. 152 such rows existed in production on 2026-09-15.
//
// Whitespace is therefore treated exactly like the comma: split only when
// **exactly one** whitespace-delimited token carries an explicit size signal.
// That is the same non-guessing rule the comma branch already uses, so
// "黑色 XL" recovers as color+size while "黑色+白色 簡約親膚" (no size token)
// keeps #274's descriptive-color behaviour untouched.
func splitOnWhitespace(value string) (color string, size string, ok bool) {
fields := strings.Fields(value)
if len(fields) < 2 {
return "", "", false
}
sizeIndex := -1
for i, field := range fields {
if explicitSizePattern.MatchString(field) {
if sizeIndex >= 0 {
// More than one size-looking token: no safe colour decision,
// same as the comma branch's both-sides-are-size case.
return "", "", false
}
sizeIndex = i
}
}
if sizeIndex < 0 {
return "", "", false
}
remainder := append(append([]string{}, fields[:sizeIndex]...), fields[sizeIndex+1:]...)
color = strings.Join(remainder, " ")
if color == "" {
return "", "", false
}
return color, fields[sizeIndex], true
}
// 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/#216 rule: split on the last comma, 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 -> a single token becomes size when it carries an
// explicit size signal ("均碼") and colour otherwise (#274's descriptive
// colours). Multiple whitespace-delimited tokens are split by the same
// one-explicit-size rule the comma branch uses, so "黑色 XL" yields both
// dimensions instead of one over-long colour (see splitOnWhitespace).
// - comma present and exactly one side has an explicit size signal -> that
// side is size and the other side is color. This supports both observed
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
// - comma present and neither side has an explicit size signal -> retain the
// established SYB color,size contract for backward compatibility.
// - comma present and both sides have explicit size signals -> uncertain;
// there is no safe color decision.
// - 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 剥离备注后为空"}
}
if explicitSizePattern.MatchString(size) {
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
}
if color, sizeToken, ok := splitOnWhitespace(size); ok {
return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess}
}
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
}
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
only := colorPart
if only == "" {
only = sizePart
}
if explicitSizePattern.MatchString(only) {
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
}
if color, sizeToken, ok := splitOnWhitespace(only); ok {
return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess}
}
if only != "" {
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
}
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
}
// StripAnnotations removes the 【...】 annotations exactly the way Parse does to
// each half of a productSpec.
//
// `[必须]` 给虞皮完整规格清单用。接口返回的是虞皮原文,而档案里的键来自
// SYB 明细经 Parse 剥离后的结果。两者必须用同一套规则,否则 confirmedMappings
// 查不到,映射全部落空(#290)。
//
// 只能用剥离,不能用 Parse 拼一个假逗号去跑:单独的半边会走进无逗号分支,
// 被空格规则再拆一次("2XL 60.0-67.5公斤" 会变成 "2XL"),与真实流程不同。
func StripAnnotations(part string) string { return stripBrackets(part) }
func stripBrackets(part string) string {
return strings.TrimSpace(bracketPattern.ReplaceAllString(part, ""))
}