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]) } if model := parseDPUModel(lotName); model != "" { return model } return "" } // parseDPUModel extracts the "{FAMILY}_{MODEL}" tail from a DPU lot_name (shape // DPU_{FAMILY}_{MODEL}, e.g. "DPU_BF3_B3220-32G" -> "BF3_B3220-32G"). DPU lots have // no port-speed suffix like NIC/HCA, so they fall through parsePortSpeed's regexes; // this keeps the family prefix (e.g. BF2 vs BF3) so distinct DPU generations sharing // a part-number suffix don't collapse into the same article token. Like parseCPUModel // it does not validate against a vendor catalog, only matches the expected shape. func parseDPUModel(lotName string) string { upper := strings.ToUpper(lotName) const prefix = "DPU_" if !strings.HasPrefix(upper, prefix) { return "" } rest := strings.TrimSpace(upper[len(prefix):]) return rest } 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, "+") }