Генерация артикула резолвила lot_category из одного прайслиста конфигурации (GetLocalLotCategoriesByServerPricelistID), поэтому world-only LOT (напр. GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE, есть только в world) молча выпадал из артикула. Теперь категории берутся через GetLocalComponentCategoriesByLotNames (тот же world ∪ estimate, что и весь конфигуратор). BuildOptions.ServerPricelist убран; preview-article принимает pricelist_id, но игнорирует. Нераспознанные токены больше не пишутся как UNK: в артикул идёт lot_category как плейсхолдер (4xGPU, 2xCPU), сегмент помечается Recognized=false, добавляется warning с именем LOT. Конфигуратор подсвечивает такие сегменты (amber) и выводит список предупреждений; сохранение/обновление/откат логируют WARN. Без каталога вендоров в репо детектируется только структурный сбой формы имени. parseGPUModel: принимает суффикс-букву в номере модели (6000D → RTX6000D), раньше терял её и схлопывал до RTX_84GB. ADL bible-local/decisions/2026-09-01-article-category-from-component-universe.md, 2026-09-01-article-degraded-token-visibility.md; раздел «Article generation» в 02-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAhfF4P1ySRZ67yyUUeVw2
662 lines
17 KiB
Go
662 lines
17 KiB
Go
package article
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
)
|
|
|
|
type BuildOptions struct {
|
|
ServerModel string
|
|
}
|
|
|
|
type BuildResult struct {
|
|
Article string
|
|
Segments []ResultSegment
|
|
Warnings []string
|
|
}
|
|
|
|
// ResultSegment is one dash-separated piece of the article, tagged so the UI can
|
|
// highlight pieces the generator could not fully parse. Recognized is false when
|
|
// the segment contains a category placeholder (e.g. "GPU" instead of a real model
|
|
// token) because a lot_name did not match the expected naming shape.
|
|
type ResultSegment struct {
|
|
Group string `json:"group"`
|
|
Text string `json:"text"`
|
|
Recognized bool `json:"recognized"`
|
|
}
|
|
|
|
var (
|
|
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
|
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
|
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
|
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
|
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
|
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
|
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
|
)
|
|
|
|
type namedSeg struct {
|
|
group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT"
|
|
value string
|
|
// degraded marks a segment whose value contains a category placeholder instead
|
|
// of a parsed spec token, because some lot_name did not match the expected shape.
|
|
degraded bool
|
|
}
|
|
|
|
// segmentResult is what each build*Segment helper returns: the rendered value, a
|
|
// degraded flag, and human-readable warnings that name the offending lot_names.
|
|
type segmentResult struct {
|
|
value string
|
|
degraded bool
|
|
warnings []string
|
|
}
|
|
|
|
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
|
segs := make([]namedSeg, 0, 8)
|
|
warnings := make([]string, 0)
|
|
|
|
model := NormalizeServerModel(opts.ServerModel)
|
|
if model == "" {
|
|
return BuildResult{}, fmt.Errorf("server_model required")
|
|
}
|
|
segs = append(segs, namedSeg{group: "MODEL", value: model})
|
|
|
|
lotNames := make([]string, 0, len(items))
|
|
for _, it := range items {
|
|
lotNames = append(lotNames, it.LotName)
|
|
}
|
|
|
|
cats, err := ResolveLotCategories(local, lotNames)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
for _, sb := range []struct {
|
|
group string
|
|
build func([]models.ConfigItem, map[string]string) segmentResult
|
|
}{
|
|
{"CPU", buildCPUSegment},
|
|
{"MEM", buildMemSegment},
|
|
{"GPU", buildGPUSegment},
|
|
{"DISK", buildDiskSegment},
|
|
{"NET", buildNetSegment},
|
|
{"PSU", buildPSUSegment},
|
|
} {
|
|
res := sb.build(items, cats)
|
|
warnings = append(warnings, res.warnings...)
|
|
if res.value != "" {
|
|
segs = append(segs, namedSeg{group: sb.group, value: res.value, degraded: res.degraded})
|
|
}
|
|
}
|
|
|
|
if supportSeg := buildSupportSegment(items); supportSeg != "" {
|
|
segs = append(segs, namedSeg{group: "SUPPORT", value: supportSeg})
|
|
}
|
|
|
|
article := strings.Join(namedSegsValues(segs), "-")
|
|
if len([]rune(article)) > 80 {
|
|
segs = compressArticle(segs)
|
|
article = strings.Join(namedSegsValues(segs), "-")
|
|
warnings = append(warnings, "compressed")
|
|
}
|
|
if len([]rune(article)) > 80 {
|
|
return BuildResult{}, fmt.Errorf("article_overflow")
|
|
}
|
|
|
|
result := BuildResult{Article: article, Warnings: warnings}
|
|
for _, s := range segs {
|
|
result.Segments = append(result.Segments, ResultSegment{
|
|
Group: s.group,
|
|
Text: s.value,
|
|
Recognized: !s.degraded,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func namedSegsValues(segs []namedSeg) []string {
|
|
out := make([]string, len(segs))
|
|
for i, s := range segs {
|
|
out[i] = s.value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findSegGroup(segs []namedSeg, group string) int {
|
|
for i, s := range segs {
|
|
if s.group == group {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// buildSupportSegment finds a support LOT in items (added to the spec via the
|
|
// Base tab's support picker, e.g. "SVC_3yB_HGX-H200") and returns its
|
|
// duration+level token ("3yB") for the article. Support is a regular BOM
|
|
// LOT, not pricelist-backed, so it's detected by lot_name prefix like the
|
|
// other lot_name-pattern parsers in this file, rather than by lot_category.
|
|
func buildSupportSegment(items []models.ConfigItem) string {
|
|
for _, it := range items {
|
|
if !strings.HasPrefix(strings.ToUpper(it.LotName), "SVC_") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(it.LotName, "_", 3)
|
|
if len(parts) >= 2 && parts[1] != "" {
|
|
return parts[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// placeholderToken returns the token used in the article when a lot's spec can't be
|
|
// parsed: the lot_category itself (never a bare "UNK"), uppercased.
|
|
func placeholderToken(cat string) string {
|
|
t := strings.ToUpper(strings.TrimSpace(cat))
|
|
if t == "" {
|
|
return "X"
|
|
}
|
|
return t
|
|
}
|
|
|
|
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
models := map[string]int{}
|
|
res := segmentResult{}
|
|
for _, it := range items {
|
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
if !ok || group != GroupCPU {
|
|
continue
|
|
}
|
|
model, parsed := parseCPUModel(it.LotName)
|
|
if !parsed {
|
|
model = placeholderToken(cats[it.LotName])
|
|
res.degraded = true
|
|
res.warnings = append(res.warnings, fmt.Sprintf("CPU: не распознана модель LOT %q — в артикул записана категория %q", it.LotName, model))
|
|
}
|
|
models[model] += it.Quantity
|
|
}
|
|
if len(models) == 0 {
|
|
return res
|
|
}
|
|
res.value = joinQtyTokens(models)
|
|
return res
|
|
}
|
|
|
|
func buildMemSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
totalGiB := 0
|
|
res := segmentResult{}
|
|
unparsed := 0
|
|
for _, it := range items {
|
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
if !ok || group != GroupMEM {
|
|
continue
|
|
}
|
|
per := parseMemGiB(it.LotName)
|
|
if per <= 0 {
|
|
unparsed++
|
|
res.degraded = true
|
|
res.warnings = append(res.warnings, fmt.Sprintf("MEM: не распознан объём LOT %q", it.LotName))
|
|
continue
|
|
}
|
|
totalGiB += per * it.Quantity
|
|
}
|
|
parts := make([]string, 0, 2)
|
|
if totalGiB > 0 {
|
|
if totalGiB%1024 == 0 {
|
|
parts = append(parts, fmt.Sprintf("%dT", totalGiB/1024))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("%dG", totalGiB))
|
|
}
|
|
}
|
|
if unparsed > 0 {
|
|
parts = append(parts, placeholderToken("MEM"))
|
|
}
|
|
res.value = strings.Join(parts, "+")
|
|
return res
|
|
}
|
|
|
|
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
models := map[string]int{}
|
|
res := segmentResult{}
|
|
for _, it := range items {
|
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
if !ok || group != GroupGPU {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
|
|
continue
|
|
}
|
|
model, parsed := parseGPUModel(it.LotName)
|
|
if !parsed {
|
|
model = placeholderToken(cats[it.LotName])
|
|
res.degraded = true
|
|
res.warnings = append(res.warnings, fmt.Sprintf("GPU: не распознана модель LOT %q — в артикул записана категория %q", it.LotName, model))
|
|
}
|
|
models[model] += it.Quantity
|
|
}
|
|
if len(models) == 0 {
|
|
return res
|
|
}
|
|
res.value = joinQtyTokens(models)
|
|
return res
|
|
}
|
|
|
|
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
type key struct {
|
|
t string
|
|
c string
|
|
}
|
|
groupQty := map[key]int{}
|
|
res := segmentResult{}
|
|
for _, it := range items {
|
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
|
if !ok || group != GroupDISK {
|
|
continue
|
|
}
|
|
capToken := parseCapacity(it.LotName)
|
|
if capToken == "" {
|
|
res.degraded = true
|
|
res.warnings = append(res.warnings, fmt.Sprintf("DISK: не распознан объём LOT %q", it.LotName))
|
|
}
|
|
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
|
groupQty[key{t: typeCode, c: capToken}] += it.Quantity
|
|
}
|
|
if len(groupQty) == 0 {
|
|
return res
|
|
}
|
|
parts := make([]string, 0, len(groupQty))
|
|
for k, qty := range groupQty {
|
|
if k.c == "" {
|
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
|
}
|
|
}
|
|
sort.Strings(parts)
|
|
res.value = strings.Join(parts, "+")
|
|
return res
|
|
}
|
|
|
|
func buildNetSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "NET")
|
|
}
|
|
|
|
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
|
|
return buildProfileSegment(items, cats, GroupPSU, parseWatts, "PSU")
|
|
}
|
|
|
|
// buildProfileSegment groups items of the given category group by a profile token
|
|
// parsed from their lot name (e.g. port speed, wattage rating). When a lot's profile
|
|
// can't be determined it falls back to the category placeholder and flags the segment
|
|
// as degraded, naming the lot in a warning.
|
|
func buildProfileSegment(items []models.ConfigItem, cats map[string]string, group Group, parseProfile func(string) string, groupLabel string) segmentResult {
|
|
groupQty := map[string]int{}
|
|
res := segmentResult{}
|
|
for _, it := range items {
|
|
g, ok := GroupForLotCategory(cats[it.LotName])
|
|
if !ok || g != group {
|
|
continue
|
|
}
|
|
profile := parseProfile(it.LotName)
|
|
if profile == "" {
|
|
profile = placeholderToken(groupLabel)
|
|
res.degraded = true
|
|
res.warnings = append(res.warnings, fmt.Sprintf("%s: не распознан профиль LOT %q — в артикул записана категория %q", groupLabel, it.LotName, profile))
|
|
}
|
|
groupQty[profile] += it.Quantity
|
|
}
|
|
if len(groupQty) == 0 {
|
|
return res
|
|
}
|
|
res.value = joinQtyTokens(groupQty)
|
|
return res
|
|
}
|
|
|
|
// joinQtyTokens renders {token: qty} as a sorted "NxTOKEN+MxTOKEN" string.
|
|
func joinQtyTokens(qty map[string]int) string {
|
|
parts := make([]string, 0, len(qty))
|
|
for token, n := range qty {
|
|
parts = append(parts, fmt.Sprintf("%dx%s", n, token))
|
|
}
|
|
sort.Strings(parts)
|
|
return strings.Join(parts, "+")
|
|
}
|
|
|
|
func normalizeModelToken(lotName string) string {
|
|
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
|
lotName = lotName[idx+1:]
|
|
}
|
|
parts := strings.Split(lotName, "_")
|
|
token := parts[len(parts)-1]
|
|
return strings.ToUpper(strings.TrimSpace(token))
|
|
}
|
|
|
|
// parseCPUModel extracts the model token from a CPU lot_name (shape
|
|
// CPU_{VENDOR}_{MODEL}, e.g. "CPU_INTEL_8592+"). The bool is false when the name
|
|
// has no parseable {VENDOR}_{MODEL} tail — a structural failure the caller surfaces.
|
|
// It cannot validate that the token is a real CPU model (no vendor catalog is kept
|
|
// in the repo by design), only that the name matched the expected shape.
|
|
func parseCPUModel(lotName string) (string, bool) {
|
|
parts := strings.Split(lotName, "_")
|
|
if len(parts) >= 2 {
|
|
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
|
if last != "" {
|
|
return last, true
|
|
}
|
|
}
|
|
return normalizeModelToken(lotName), false
|
|
}
|
|
|
|
// parseGPUModel extracts a "MODEL[_MEM]" token from a GPU lot_name. The bool is
|
|
// false when no model token could be located and the result is only the last-ditch
|
|
// last-underscore-segment fallback — a structural failure the caller surfaces.
|
|
// Like parseCPUModel it does not validate the token against a catalog.
|
|
func parseGPUModel(lotName string) (string, bool) {
|
|
upper := strings.ToUpper(lotName)
|
|
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
|
upper = upper[idx+4:]
|
|
}
|
|
parts := strings.Split(upper, "_")
|
|
model := ""
|
|
numSuffix := ""
|
|
mem := ""
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
switch p {
|
|
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
|
|
continue
|
|
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
|
|
if model != "" {
|
|
archAbbr := map[string]string{
|
|
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
|
|
}
|
|
numSuffix += archAbbr[p]
|
|
}
|
|
continue
|
|
default:
|
|
if strings.Contains(p, "GB") {
|
|
mem = p
|
|
continue
|
|
}
|
|
if model == "" && i > 0 {
|
|
model = p
|
|
} else if model != "" && numSuffix == "" && isModelNumber(p) {
|
|
numSuffix = p
|
|
}
|
|
}
|
|
}
|
|
full := model
|
|
if numSuffix != "" {
|
|
full = model + numSuffix
|
|
}
|
|
if full != "" && mem != "" {
|
|
return full + "_" + mem, true
|
|
}
|
|
if full != "" {
|
|
return full, true
|
|
}
|
|
return normalizeModelToken(lotName), false
|
|
}
|
|
|
|
// isModelNumber reports whether s looks like a GPU/accelerator model number token:
|
|
// it starts with a digit and contains only digits and uppercase letters. This keeps
|
|
// vendor-suffixed names like "6000D" (RTX PRO 6000D) intact instead of dropping the
|
|
// letter and collapsing two distinct models to the same token.
|
|
func isModelNumber(s string) bool {
|
|
if s == "" || s[0] < '0' || s[0] > '9' {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseMemGiB(lotName string) int {
|
|
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
|
return atoi(m[1]) * 1024
|
|
}
|
|
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
|
return atoi(m[1])
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseCapacity(lotName string) string {
|
|
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
|
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
|
}
|
|
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
|
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func diskTypeCode(cat string, lotName string) string {
|
|
c := strings.ToUpper(strings.TrimSpace(cat))
|
|
if c == "M2" {
|
|
return "M2"
|
|
}
|
|
upper := strings.ToUpper(lotName)
|
|
if strings.Contains(upper, "NVME") {
|
|
return "NV"
|
|
}
|
|
if strings.Contains(upper, "SAS") {
|
|
return "SAS"
|
|
}
|
|
if strings.Contains(upper, "SATA") {
|
|
return "SAT"
|
|
}
|
|
return c
|
|
}
|
|
|
|
func parsePortSpeed(lotName string) string {
|
|
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
|
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
|
}
|
|
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
|
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseWatts(lotName string) string {
|
|
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
|
w := atoi(m[1])
|
|
if w >= 1000 {
|
|
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
|
kw = strings.TrimSuffix(kw, ".0")
|
|
return fmt.Sprintf("%skW", kw)
|
|
}
|
|
return fmt.Sprintf("%dW", w)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeNumberToken(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
raw = strings.TrimLeft(raw, "0")
|
|
if raw == "" || raw[0] == '.' {
|
|
raw = "0" + raw
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func normalizeTToken(raw string) string {
|
|
raw = normalizeNumberToken(raw)
|
|
parts := strings.SplitN(raw, ".", 2)
|
|
intPart := parts[0]
|
|
frac := ""
|
|
if len(parts) == 2 {
|
|
frac = parts[1]
|
|
}
|
|
if frac == "" {
|
|
frac = "0"
|
|
}
|
|
if len(intPart) >= 2 {
|
|
return intPart + "." + frac
|
|
}
|
|
if len(frac) > 1 {
|
|
frac = frac[:1]
|
|
}
|
|
return intPart + "." + frac
|
|
}
|
|
|
|
func atoi(v string) int {
|
|
n := 0
|
|
for _, r := range v {
|
|
if r < '0' || r > '9' {
|
|
continue
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
// compressArticle shortens an over-long article by progressively dropping/abbreviating
|
|
// segments. It returns the (possibly shortened) segment list; the caller re-joins it.
|
|
func compressArticle(segs []namedSeg) []namedSeg {
|
|
if len(segs) == 0 {
|
|
return segs
|
|
}
|
|
fits := func() bool {
|
|
return len([]rune(strings.Join(namedSegsValues(segs), "-"))) <= 80
|
|
}
|
|
for i, s := range segs {
|
|
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
|
|
}
|
|
if fits() {
|
|
return segs
|
|
}
|
|
|
|
// 1) remove PSU
|
|
if i := findSegGroup(segs, "PSU"); i >= 0 {
|
|
segs = append(segs[:i], segs[i+1:]...)
|
|
if fits() {
|
|
return segs
|
|
}
|
|
}
|
|
|
|
// 2) compress NET/HBA/HCA
|
|
if i := findSegGroup(segs, "NET"); i >= 0 {
|
|
segs[i].value = compressNetSegment(segs[i].value)
|
|
if fits() {
|
|
return segs
|
|
}
|
|
}
|
|
|
|
// 3) compress DISK
|
|
if i := findSegGroup(segs, "DISK"); i >= 0 {
|
|
segs[i].value = compressDiskSegment(segs[i].value)
|
|
if fits() {
|
|
return segs
|
|
}
|
|
}
|
|
|
|
// 4) compress GPU to vendor only (GPU_NV)
|
|
if i := findSegGroup(segs, "GPU"); i >= 0 {
|
|
segs[i].value = compressGPUSegment(segs[i].value)
|
|
}
|
|
return segs
|
|
}
|
|
|
|
func compressNetSegment(seg string) string {
|
|
if seg == "" {
|
|
return seg
|
|
}
|
|
parts := strings.Split(seg, "+")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
qty := "1"
|
|
profile := p
|
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
qty = x[0]
|
|
profile = x[1]
|
|
}
|
|
upper := strings.ToUpper(profile)
|
|
label := "NIC"
|
|
if strings.Contains(upper, "FC") {
|
|
label = "HBA"
|
|
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
|
|
label = "HCA"
|
|
}
|
|
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
|
}
|
|
if len(out) == 0 {
|
|
return seg
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, "+")
|
|
}
|
|
|
|
func compressDiskSegment(seg string) string {
|
|
if seg == "" {
|
|
return seg
|
|
}
|
|
parts := strings.Split(seg, "+")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
qty := "1"
|
|
spec := p
|
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
qty = x[0]
|
|
spec = x[1]
|
|
}
|
|
upper := strings.ToUpper(spec)
|
|
label := "DSK"
|
|
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
|
|
if strings.Contains(upper, t) {
|
|
label = t
|
|
break
|
|
}
|
|
}
|
|
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
|
}
|
|
if len(out) == 0 {
|
|
return seg
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, "+")
|
|
}
|
|
|
|
func compressGPUSegment(seg string) string {
|
|
if seg == "" {
|
|
return seg
|
|
}
|
|
parts := strings.Split(seg, "+")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
qty := "1"
|
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
|
qty = x[0]
|
|
}
|
|
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
|
|
}
|
|
if len(out) == 0 {
|
|
return seg
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, "+")
|
|
}
|