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 SupportCode string ServerPricelist *uint } type BuildResult struct { Article string Warnings []string } 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`) ) func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) { segments := make([]string, 0, 8) warnings := make([]string, 0) model := NormalizeServerModel(opts.ServerModel) if model == "" { return BuildResult{}, fmt.Errorf("server_model required") } segments = append(segments, model) lotNames := make([]string, 0, len(items)) for _, it := range items { lotNames = append(lotNames, it.LotName) } if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 { return BuildResult{}, fmt.Errorf("pricelist_id required for article") } cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames) if err != nil { return BuildResult{}, err } cpuSeg := buildCPUSegment(items, cats) if cpuSeg != "" { segments = append(segments, cpuSeg) } memSeg, memWarn := buildMemSegment(items, cats) if memWarn != "" { warnings = append(warnings, memWarn) } if memSeg != "" { segments = append(segments, memSeg) } gpuSeg := buildGPUSegment(items, cats) if gpuSeg != "" { segments = append(segments, gpuSeg) } diskSeg, diskWarn := buildDiskSegment(items, cats) if diskWarn != "" { warnings = append(warnings, diskWarn) } if diskSeg != "" { segments = append(segments, diskSeg) } netSeg, netWarn := buildNetSegment(items, cats) if netWarn != "" { warnings = append(warnings, netWarn) } if netSeg != "" { segments = append(segments, netSeg) } psuSeg, psuWarn := buildPSUSegment(items, cats) if psuWarn != "" { warnings = append(warnings, psuWarn) } if psuSeg != "" { segments = append(segments, psuSeg) } if strings.TrimSpace(opts.SupportCode) != "" { code := strings.TrimSpace(opts.SupportCode) if !isSupportCodeValid(code) { return BuildResult{}, fmt.Errorf("invalid_support_code") } segments = append(segments, code) } article := strings.Join(segments, "-") if len([]rune(article)) > 80 { article = compressArticle(segments) warnings = append(warnings, "compressed") } if len([]rune(article)) > 80 { return BuildResult{}, fmt.Errorf("article_overflow") } return BuildResult{Article: article, Warnings: warnings}, nil } func isSupportCodeValid(code string) bool { if len(code) < 3 { return false } if !strings.Contains(code, "y") { return false } parts := strings.Split(code, "y") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return false } for _, r := range parts[0] { if r < '0' || r > '9' { return false } } switch parts[1] { case "W", "B", "S", "P": return true default: return false } } func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string { type agg struct { qty int } models := map[string]*agg{} total := 0 for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupCPU { continue } model := parseCPUModel(it.LotName) if model == "" { model = "UNK" } if _, ok := models[model]; !ok { models[model] = &agg{} } models[model].qty += it.Quantity total += it.Quantity } if total == 0 { return "" } if len(models) == 1 { for model, a := range models { return fmt.Sprintf("%dx%s", a.qty, model) } } if len(models) <= 2 { parts := make([]string, 0, len(models)) for model, a := range models { parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model)) } sort.Strings(parts) return strings.Join(parts, "+") } return fmt.Sprintf("%dxMIX", total) } func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) { totalGiB := 0 for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupMEM { continue } per := parseMemGiB(it.LotName) if per <= 0 { return "", "mem_unknown" } totalGiB += per * it.Quantity } if totalGiB == 0 { return "", "" } if totalGiB%1024 == 0 { return fmt.Sprintf("%dT", totalGiB/1024), "" } return fmt.Sprintf("%dG", totalGiB), "" } func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string { models := map[string]int{} total := 0 for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupGPU { continue } model := parseGPUModel(it.LotName) if model == "" { model = "UNK" } models[model] += it.Quantity total += it.Quantity } if total == 0 { return "" } if len(models) <= 2 { parts := make([]string, 0, len(models)) for model, qty := range models { parts = append(parts, fmt.Sprintf("%dx%s", qty, model)) } sort.Strings(parts) return strings.Join(parts, "+") } return fmt.Sprintf("%dxMIX", total) } func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) { type key struct { t string c string } groupQty := map[key]int{} total := 0 warn := "" for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupDISK { continue } capToken := parseCapacity(it.LotName) if capToken == "" { warn = "disk_unknown" } typeCode := diskTypeCode(cats[it.LotName], it.LotName) k := key{t: typeCode, c: capToken} groupQty[k] += it.Quantity total += it.Quantity } if total == 0 { return "", "" } 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) if len(parts) > 2 { return fmt.Sprintf("%dxMIXD", total), warn } return strings.Join(parts, "+"), warn } func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) { groupQty := map[string]int{} total := 0 warn := "" for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupNET { continue } profile := parsePortSpeed(it.LotName) if profile == "" { profile = "UNKNET" warn = "net_unknown" } groupQty[profile] += it.Quantity total += it.Quantity } if total == 0 { return "", "" } parts := make([]string, 0, len(groupQty)) for profile, qty := range groupQty { parts = append(parts, fmt.Sprintf("%dx%s", qty, profile)) } sort.Strings(parts) if len(parts) > 2 { return fmt.Sprintf("%dxMIXN", total), warn } return strings.Join(parts, "+"), warn } func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) { groupQty := map[string]int{} total := 0 warn := "" for _, it := range items { group, ok := GroupForLotCategory(cats[it.LotName]) if !ok || group != GroupPSU { continue } rating := parseWatts(it.LotName) if rating == "" { rating = "UNKPSU" warn = "psu_unknown" } groupQty[rating] += it.Quantity total += it.Quantity } if total == 0 { return "", "" } parts := make([]string, 0, len(groupQty)) for rating, qty := range groupQty { parts = append(parts, fmt.Sprintf("%dx%s", qty, rating)) } sort.Strings(parts) if len(parts) > 2 { return fmt.Sprintf("%dxMIXP", total), warn } return strings.Join(parts, "+"), warn } 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)) } func parseCPUModel(lotName string) string { parts := strings.Split(lotName, "_") if len(parts) >= 2 { last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1])) if last != "" { return last } } return normalizeModelToken(lotName) } func parseGPUModel(lotName string) string { upper := strings.ToUpper(lotName) if idx := strings.Index(upper, "GPU_"); idx >= 0 { upper = upper[idx+4:] } parts := strings.Split(upper, "_") model := "" mem := "" for i, p := range parts { if p == "" { continue } switch p { case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX": continue default: if strings.Contains(p, "GB") { mem = p continue } if model == "" && (i > 0) { model = p } } } if model != "" && mem != "" { return model + "_" + mem } if model != "" { return model } return normalizeModelToken(lotName) } 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 } func compressArticle(segments []string) string { if len(segments) == 0 { return "" } normalized := make([]string, 0, len(segments)) for _, s := range segments { normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G")) } return strings.Join(normalized, "-") }