497 lines
11 KiB
Go
497 lines
11 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
|
|
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, "-")
|
|
}
|