fix: артикул — категории из component universe + видимость нераспознанных токенов

Генерация артикула резолвила 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
This commit is contained in:
Mikhail Chusavitin
2026-09-01 09:01:56 +03:00
co-authored by Claude Sonnet 5
parent ecef030699
commit d7d4ea74b6
11 changed files with 512 additions and 153 deletions
+8 -5
View File
@@ -42,14 +42,17 @@ func GroupForLotCategory(cat string) (group Group, ok bool) {
}
}
// ResolveLotCategories returns lot_category for each lotName found in local_pricelist_items
// for the given server pricelist. Lots not found in the pricelist are omitted from the result —
// callers must treat a missing key as "no category" and skip that lot.
func ResolveLotCategories(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
// ResolveLotCategories returns lot_category for each lotName, read from the component
// universe (latest active world estimate pricelists — the same source the configurator,
// BOM and pricing tab use, see bible-local/decisions/2026-07-24-component-universe-world-union.md).
// It must NOT be scoped to a single pricelist: a world-only LOT is a legitimate cart member
// and still carries a real lot_category in the world pricelist. LOTs present in neither
// pricelist are omitted — callers treat a missing key as "no category" and skip that lot.
func ResolveLotCategories(local *localdb.LocalDB, lotNames []string) (map[string]string, error) {
if local == nil {
return nil, fmt.Errorf("local db is nil")
}
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
cats, err := local.GetLocalComponentCategoriesByLotNames(lotNames)
if err != nil {
return nil, err
}
+51 -2
View File
@@ -20,6 +20,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
IsActive: true,
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
@@ -35,7 +36,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
t.Fatalf("save local items: %v", err)
}
cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
cats, err := ResolveLotCategories(local, []string{"CPU_A", "UNKNOWN"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -76,7 +77,7 @@ func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
t.Fatalf("save items: %v", err)
}
cats, err := ResolveLotCategories(local, 1, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
cats, err := ResolveLotCategories(local, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -91,6 +92,54 @@ func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
}
}
// TestResolveLotCategories_WorldOnlyLot covers a LOT that exists only in the world
// pricelist (never added to estimate) but is a legitimate cart member. Its real
// lot_category must be resolved from the component universe, not dropped.
func TestResolveLotCategories_WorldOnlyLot(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
saved := func(serverID uint, source string, items []localdb.LocalPricelistItem) {
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverID, Source: source, Version: source + "-v1", Name: source,
IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save %s pricelist: %v", source, err)
}
pl, err := local.GetLocalPricelistByServerID(serverID)
if err != nil {
t.Fatalf("get %s pricelist: %v", source, err)
}
for i := range items {
items[i].PricelistID = pl.ID
}
if err := local.SaveLocalPricelistItems(items); err != nil {
t.Fatalf("save %s items: %v", source, err)
}
}
saved(1, "estimate", []localdb.LocalPricelistItem{
{LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
})
saved(2, "world", []localdb.LocalPricelistItem{
{LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
{LotName: "GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE", LotCategory: "GPU", Price: 9900},
})
cats, err := ResolveLotCategories(local, []string{
"CPU_INTEL_8358", "GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cats["GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE"] != "GPU" {
t.Fatalf("world-only LOT category not resolved: %q", cats["GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE"])
}
}
func TestGroupForLotCategory(t *testing.T) {
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
t.Fatalf("expected cpu -> GroupCPU")
+173 -128
View File
@@ -11,15 +11,25 @@ import (
)
type BuildOptions struct {
ServerModel string
ServerPricelist *uint
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)`)
@@ -33,6 +43,17 @@ var (
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) {
@@ -43,71 +64,59 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
if model == "" {
return BuildResult{}, fmt.Errorf("server_model required")
}
segs = append(segs, namedSeg{"MODEL", model})
segs = append(segs, namedSeg{group: "MODEL", value: 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 := ResolveLotCategories(local, *opts.ServerPricelist, lotNames)
cats, err := ResolveLotCategories(local, lotNames)
if err != nil {
return BuildResult{}, err
}
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" {
segs = append(segs, namedSeg{"CPU", cpuSeg})
}
memSeg, memWarn := buildMemSegment(items, cats)
if memWarn != "" {
warnings = append(warnings, memWarn)
}
if memSeg != "" {
segs = append(segs, namedSeg{"MEM", memSeg})
}
if gpuSeg := buildGPUSegment(items, cats); gpuSeg != "" {
segs = append(segs, namedSeg{"GPU", gpuSeg})
}
diskSeg, diskWarn := buildDiskSegment(items, cats)
if diskWarn != "" {
warnings = append(warnings, diskWarn)
}
if diskSeg != "" {
segs = append(segs, namedSeg{"DISK", diskSeg})
}
netSeg, netWarn := buildNetSegment(items, cats)
if netWarn != "" {
warnings = append(warnings, netWarn)
}
if netSeg != "" {
segs = append(segs, namedSeg{"NET", netSeg})
}
psuSeg, psuWarn := buildPSUSegment(items, cats)
if psuWarn != "" {
warnings = append(warnings, psuWarn)
}
if psuSeg != "" {
segs = append(segs, namedSeg{"PSU", psuSeg})
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{"SUPPORT", supportSeg})
segs = append(segs, namedSeg{group: "SUPPORT", value: supportSeg})
}
article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) > 80 {
article = compressArticle(segs)
segs = compressArticle(segs)
article = strings.Join(namedSegsValues(segs), "-")
warnings = append(warnings, "compressed")
}
if len([]rune(article)) > 80 {
return BuildResult{}, fmt.Errorf("article_overflow")
}
return BuildResult{Article: article, Warnings: warnings}, nil
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 {
@@ -145,38 +154,43 @@ func buildSupportSegment(items []models.ConfigItem) string {
return ""
}
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
type agg struct {
qty int
// 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"
}
models := map[string]*agg{}
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 := parseCPUModel(it.LotName)
if model == "" {
model = "UNK"
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))
}
if _, ok := models[model]; !ok {
models[model] = &agg{}
}
models[model].qty += it.Quantity
models[model] += it.Quantity
}
if len(models) == 0 {
return ""
return res
}
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, "+")
res.value = joinQtyTokens(models)
return res
}
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
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 {
@@ -184,21 +198,31 @@ func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string,
}
per := parseMemGiB(it.LotName)
if per <= 0 {
return "", "mem_unknown"
unparsed++
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("MEM: не распознан объём LOT %q", it.LotName))
continue
}
totalGiB += per * it.Quantity
}
if totalGiB == 0 {
return "", ""
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 totalGiB%1024 == 0 {
return fmt.Sprintf("%dT", totalGiB/1024), ""
if unparsed > 0 {
parts = append(parts, placeholderToken("MEM"))
}
return fmt.Sprintf("%dG", totalGiB), ""
res.value = strings.Join(parts, "+")
return res
}
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
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 {
@@ -207,30 +231,28 @@ func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
continue
}
model := parseGPUModel(it.LotName)
if model == "" {
model = "UNK"
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 ""
return res
}
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, "+")
res.value = joinQtyTokens(models)
return res
}
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
type key struct {
t string
c string
}
groupQty := map[key]int{}
warn := ""
res := segmentResult{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupDISK {
@@ -238,14 +260,14 @@ func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string
}
capToken := parseCapacity(it.LotName)
if capToken == "" {
warn = "disk_unknown"
res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("DISK: не распознан объём LOT %q", it.LotName))
}
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
k := key{t: typeCode, c: capToken}
groupQty[k] += it.Quantity
groupQty[key{t: typeCode, c: capToken}] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
return res
}
parts := make([]string, 0, len(groupQty))
for k, qty := range groupQty {
@@ -256,23 +278,25 @@ func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string
}
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
res.value = strings.Join(parts, "+")
return res
}
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "UNKNET", "net_unknown")
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) (string, string) {
return buildProfileSegment(items, cats, GroupPSU, parseWatts, "UNKPSU", "psu_unknown")
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), falling back to
// unknownToken and warnCode when a lot's profile can't be determined.
func buildProfileSegment(items []models.ConfigItem, cats map[string]string, group Group, parseProfile func(string) string, unknownToken, warnCode string) (string, string) {
// 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{}
warn := ""
res := segmentResult{}
for _, it := range items {
g, ok := GroupForLotCategory(cats[it.LotName])
if !ok || g != group {
@@ -280,20 +304,27 @@ func buildProfileSegment(items []models.ConfigItem, cats map[string]string, grou
}
profile := parseProfile(it.LotName)
if profile == "" {
profile = unknownToken
warn = warnCode
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 "", ""
return res
}
parts := make([]string, 0, len(groupQty))
for profile, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
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, "+"), warn
return strings.Join(parts, "+")
}
func normalizeModelToken(lotName string) string {
@@ -305,18 +336,27 @@ func normalizeModelToken(lotName string) string {
return strings.ToUpper(strings.TrimSpace(token))
}
func parseCPUModel(lotName string) string {
// 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
return last, true
}
}
return normalizeModelToken(lotName)
return normalizeModelToken(lotName), false
}
func parseGPUModel(lotName string) string {
// 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:]
@@ -347,7 +387,7 @@ func parseGPUModel(lotName string) string {
}
if model == "" && i > 0 {
model = p
} else if model != "" && numSuffix == "" && isNumeric(p) {
} else if model != "" && numSuffix == "" && isModelNumber(p) {
numSuffix = p
}
}
@@ -357,20 +397,24 @@ func parseGPUModel(lotName string) string {
full = model + numSuffix
}
if full != "" && mem != "" {
return full + "_" + mem
return full + "_" + mem, true
}
if full != "" {
return full
return full, true
}
return normalizeModelToken(lotName)
return normalizeModelToken(lotName), false
}
func isNumeric(s string) bool {
if s == "" {
// 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' {
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') {
return false
}
}
@@ -478,42 +522,43 @@ func atoi(v string) int {
return n
}
func compressArticle(segs []namedSeg) string {
// 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 ""
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")
}
article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
if fits() {
return segs
}
// 1) remove PSU
if i := findSegGroup(segs, "PSU"); i >= 0 {
segs = append(segs[:i], segs[i+1:]...)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
if fits() {
return segs
}
}
// 2) compress NET/HBA/HCA
if i := findSegGroup(segs, "NET"); i >= 0 {
segs[i].value = compressNetSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
if fits() {
return segs
}
}
// 3) compress DISK
if i := findSegGroup(segs, "DISK"); i >= 0 {
segs[i].value = compressDiskSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
if fits() {
return segs
}
}
@@ -521,7 +566,7 @@ func compressArticle(segs []namedSeg) string {
if i := findSegGroup(segs, "GPU"); i >= 0 {
segs[i].value = compressGPUSegment(segs[i].value)
}
return strings.Join(namedSegsValues(segs), "-")
return segs
}
func compressNetSegment(seg string) string {
+68 -4
View File
@@ -47,8 +47,7 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
{LotName: "SVC_1yW_x86", Quantity: 1},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11",
ServerPricelist: &localPL.ServerID,
ServerModel: "DL380GEN11",
})
if err != nil {
t.Fatalf("build article: %v", err)
@@ -118,8 +117,7 @@ func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
{LotName: "PS_1500W_Platinum", Quantity: 2},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "NF5280M6",
ServerPricelist: &localPL.ServerID,
ServerModel: "NF5280M6",
})
if err != nil {
t.Fatalf("build article: %v", err)
@@ -140,3 +138,69 @@ func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
func contains(s, sub string) bool {
return strings.Contains(s, sub)
}
func TestParseGPUModel_VendorLetterSuffix(t *testing.T) {
cases := map[string]string{
"GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE": "RTX6000D_84GB",
"GPU_NV_RTX_PRO_6000_SERVER_96GB_PCIE": "RTX6000_96GB",
}
for in, want := range cases {
got, ok := parseGPUModel(in)
if !ok || got != want {
t.Errorf("parseGPUModel(%q) = %q, %v, want %q, true", in, got, ok, want)
}
}
}
// TestBuild_UnparseableModel_CategoryPlaceholder: a GPU LOT whose name does not
// match the expected shape must not silently vanish or emit "UNK" — the article
// carries the category token and the segment is flagged not-recognized with a
// warning naming the lot.
func TestBuild_UnparseableModel_CategoryPlaceholder(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 9, Source: "estimate", Version: "v1", Name: "t",
IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save pricelist: %v", err)
}
pl, err := local.GetLocalPricelistByServerID(9)
if err != nil {
t.Fatalf("get pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: pl.ID, LotName: "WEIRDGPUNAME", LotCategory: "GPU", Price: 1},
}); err != nil {
t.Fatalf("save items: %v", err)
}
result, err := Build(local, models.ConfigItems{
{LotName: "WEIRDGPUNAME", Quantity: 4},
}, BuildOptions{ServerModel: "X1"})
if err != nil {
t.Fatalf("build: %v", err)
}
if contains(result.Article, "UNK") {
t.Fatalf("article must not contain UNK: %s", result.Article)
}
if !contains(result.Article, "4xGPU") {
t.Fatalf("expected category placeholder 4xGPU in article: %s", result.Article)
}
var gpu *ResultSegment
for i := range result.Segments {
if result.Segments[i].Group == "GPU" {
gpu = &result.Segments[i]
}
}
if gpu == nil || gpu.Recognized {
t.Fatalf("GPU segment must be present and not recognized: %+v", result.Segments)
}
if len(result.Warnings) == 0 || !contains(strings.Join(result.Warnings, "|"), "WEIRDGPUNAME") {
t.Fatalf("expected a warning naming WEIRDGPUNAME, got %v", result.Warnings)
}
}
+3 -1
View File
@@ -40,5 +40,7 @@ type ArticlePreviewRequest struct {
Items models.ConfigItems `json:"items"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
// PricelistID is accepted for backward compatibility but ignored: article
// categories come from the component universe (world estimate), not one pricelist.
PricelistID *uint `json:"pricelist_id,omitempty"`
}
+24 -12
View File
@@ -70,13 +70,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
ServerModel: req.ServerModel,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
total := req.Items.Total()
@@ -166,13 +171,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
ServerModel: req.ServerModel,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
total := req.Items.Total()
@@ -216,13 +226,10 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
// BuildArticlePreview generates server article based on current items and server_model/support_code.
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return article.BuildResult{}, err
}
// Categories for the article come from the component universe (world estimate),
// not from a specific pricelist, so req.PricelistID is no longer needed here.
return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
ServerModel: req.ServerModel,
})
}
@@ -527,13 +534,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
ServerModel: req.ServerModel,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
if len(articleResult.Warnings) > 0 {
slog.Warn("article generation degraded",
"server_model", req.ServerModel,
"article", articleResult.Article,
"warnings", articleResult.Warnings)
}
}
total := req.Items.Total()