172 lines
8.0 KiB
Go
172 lines
8.0 KiB
Go
// 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, ""))
|
||
}
|