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
+28
View File
@@ -157,6 +157,34 @@ Rules:
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere; - `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them. - `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
## Article generation
`internal/article` builds the `article` string (`{MODEL}-{CPU}-{MEM}-{GPU}-{DISK}-{NET}-{PSU}-{SUPPORT}`)
from the configuration's `items` + `server_model`.
- which segment a cart LOT belongs to is decided by its `lot_category`, resolved through
`ResolveLotCategories``GetLocalComponentCategoriesByLotNames`, i.e. the **component
universe** (latest active `world` `estimate`), the same source the configurator/BOM/pricing
tab use. It must **not** be scoped to the configuration's pinned pricelist: a world-only LOT
(e.g. `GPU_NV_RTX_PRO_6000D_...`, priced by the world fallback, never added to the estimate
pricelist) is a legitimate cart member and still carries a real `lot_category` in `world`.
Scoping to one pricelist silently dropped such LOTs from the article. See
[decisions/2026-09-01-article-category-from-component-universe.md](decisions/2026-09-01-article-category-from-component-universe.md);
- `BuildOptions` no longer takes a pricelist; `POST /api/configs/preview-article` still accepts
`pricelist_id` but ignores it;
- category comes only from real synced pricelist columns — never inferred from the `lot_name`
prefix (the `SVC_` SUPPORT segment is the sole lot_name-pattern exception, see below);
- within a segment the model/capacity/speed **token** is still parsed from the `lot_name`
that is the article text itself, not categorization;
- when a token can't be parsed (the `lot_name` doesn't fit `{GROUP}_{VENDOR}_{MODEL}[_{SPEC}…]`)
the segment carries the LOT's `lot_category` as the token — **never** a bare `UNK`. Such a
segment comes back with `Recognized = false` in `BuildResult.Segments`, plus a `Warnings`
entry naming the `lot_name`. The configurator highlights the segment (amber) and lists the
warnings; create/update/rollback log `WARN "article generation degraded"`. No catalog of real
lot_names / model names lives in the repo (`no-hardcoded-vendors`), so only *structural*
parse failure is detected, not a wrong-but-well-formed token. See
[decisions/2026-09-01-article-degraded-token-visibility.md](decisions/2026-09-01-article-degraded-token-visibility.md).
## Support as a BOM LOT ## Support as a BOM LOT
The Base tab's support-level picker adds/replaces a synthetic LOT in `cart` (e.g. The Base tab's support-level picker adds/replaces a synthetic LOT in `cart` (e.g.
@@ -0,0 +1,62 @@
# Decision: article generation reads lot_category from the component universe, not one pricelist
**Date:** 2026-09-01
**Status:** active
## Context
`internal/article/generator.go` classifies each cart LOT into an article segment
(CPU / MEM / GPU / DISK / NET / PSU) by its `lot_category`. That category was
resolved by `ResolveLotCategories``GetLocalLotCategoriesByServerPricelistID`,
which queries exactly **one** pricelist — the configuration's pinned
`pricelist_id` (an `estimate` pricelist). A segment builder skips any LOT whose
category is unknown, so a LOT absent from that one pricelist vanished from the
article with no warning.
`GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE` exposed this: it exists only in `world`
pricelists (where it correctly carries `lot_category = GPU`) and in no `estimate`
pricelist. It was priced (world fallback), exported, and shown in the cart, but
dropped from the article.
This is the same class of bug as
[2026-07-24-component-universe-world-union.md](2026-07-24-component-universe-world-union.md):
the cart's LOT set is `world` `estimate`, and no read path may silently drop a
LOT that only lives in `world`. Article generation was the one component-reading
path still scoped to a single pricelist.
Deriving the category from the `lot_name` prefix was rejected — it is forbidden by
the pricelist-contract rules (`02-architecture.md`, `bible/rules/patterns/`):
category comes only from real synced pricelist columns.
## Decision
`ResolveLotCategories(local, lotNames)` now calls
`LocalDB.GetLocalComponentCategoriesByLotNames`, which reads the component universe
(`componentUniverse()` — latest active `world` `estimate`, estimate wins a
collision). The server-pricelist parameter is gone.
- `article.BuildOptions` no longer has `ServerPricelist`.
- `POST /api/configs/preview-article` and the create/update paths still accept
`pricelist_id` but ignore it for article purposes.
- The `lot_name`-token parsing inside a segment (CPU model, memory size, GPU model,
disk capacity, port speed, wattage) still parses the token from the `lot_name`
that produces the article text, it does not categorize. One parser bug was fixed
alongside: `parseGPUModel` required the model-number token to be all digits
(`isNumeric`), so `RTX_PRO_6000D` lost its `D` and collapsed to `RTX_84GB`
indistinguishable from a real `RTX` card and different from its sibling
`RTX_PRO_6000``RTX6000_96GB`. It now accepts a digit-first alphanumeric token
(`isModelNumber`), giving `RTX6000D_84GB`.
## Consequences
- A world-only LOT lands in the article with its real category. Article generation
no longer depends on which pricelist a LOT was added to.
- The article can shift if the latest active `world`/`estimate` pricelist
recategorizes a LOT — acceptable, and consistent with how the configurator's
category tabs already behave.
- `GetLocalLotCategoriesByServerPricelistID` still exists and is still used by
`services/rental.go` and `services/export.go`. Those paths have the same
single-pricelist blind spot for world-only LOTs; migrating them is out of scope
here but should follow the same direction.
- Covered by `TestResolveLotCategories_WorldOnlyLot` in
`internal/article/categories_test.go`.
@@ -0,0 +1,66 @@
# Decision: unparsed article tokens degrade to a visible category placeholder, never "UNK"
**Date:** 2026-09-01
**Status:** active
## Context
`internal/article` builds each segment by parsing a spec token out of the `lot_name`
(CPU model, memory size, GPU model, disk capacity, port speed, wattage). When a
`lot_name` did not match the expected shape the generator failed silently or
misleadingly:
- `parseCPUModel` / `parseGPUModel` fell back to `normalizeModelToken` or a literal
`"UNK"` with **no signal** — and a partial parse (`RTX_PRO_6000D``RTX_84GB`)
produced a plausible but wrong token indistinguishable from a real result;
- `NET` / `PSU` emitted `UNKNET` / `UNKPSU`;
- `MEM` / `DISK` dropped the segment or the capacity and emitted only a terse
`mem_unknown` / `disk_unknown` warning;
- `BuildResult.Warnings` existed but **nothing consumed it**: the `preview-article`
frontend ignored the field and the create/update paths discarded it.
So a new naming shape (a vendor renames a card family, moves memory ahead of the
model, glues an architecture onto the token) would silently produce a wrong or
truncated article and no one would notice.
A vendor/model catalog in the repo to validate tokens against was rejected — it
violates `bible/rules/patterns/no-hardcoded-vendors` and needs constant upkeep.
Detection is therefore limited to **structural** failure (the name doesn't fit
`{GROUP}_{VENDOR}_{MODEL}[_{SPEC}…]`); semantic drift where a wrong token still
parses cleanly cannot be caught automatically and is out of scope.
## Decision
1. `"UNK"` / `"UNKNET"` / `"UNKPSU"` are gone. When a spec token can't be parsed the
article carries the LOT's `lot_category` as the token (`4xGPU`, `2xCPU`,
`768G+MEM`, …) via `placeholderToken`.
2. Each `build*Segment` returns a `segmentResult{value, degraded, warnings}`. Every
degraded segment produces a warning that **names the offending `lot_name`** and
states which category was written instead.
3. `BuildResult` gains `Segments []ResultSegment{Group, Text, Recognized}`.
`Recognized == false` marks a segment whose token is a placeholder.
4. `parseCPUModel` / `parseGPUModel` return `(token, ok)`; `ok == false` on the
last-ditch fallback path.
5. Consumers surface it:
- `POST /api/configs/preview-article` returns `segments` and `warnings`; the
configurator's article line renders each not-`recognized` segment highlighted
(amber) and lists the warnings beneath it (`renderArticleDisplay` in
`web/templates/index.html`);
- create / update / rollback log `WARN "article generation degraded"` with the
`server_model`, article and warnings.
6. No list of real `lot_name`s or model names is committed to the repo — not as a
fixture, not as a golden table. Tests use synthetic names only.
## Consequences
- An unrecognised LOT is always visible in the UI (highlighted token + warning) and
in server logs — never a silent drop or a bare `UNK`.
- The stored `article` string still contains only the placeholder token; the
`recognized` flags are not persisted (regenerated on next save / preview).
- `compressArticle` now returns `[]namedSeg` (the caller re-joins) so the degraded
flags survive compression.
- Structural detection only: a wrong-but-well-formed token (e.g. a future `GTX`
where `RTX` was expected) still passes. Closing that needs a validation source,
deferred pending a server-side structured-attribute contract.
- Covered by `TestBuild_UnparseableModel_CategoryPlaceholder` and
`TestParseGPUModel_VendorLetterSuffix`.
+1
View File
@@ -1086,6 +1086,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"article": result.Article, "article": result.Article,
"segments": result.Segments,
"warnings": result.Warnings, "warnings": result.Warnings,
}) })
}) })
+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 // ResolveLotCategories returns lot_category for each lotName, read from the component
// for the given server pricelist. Lots not found in the pricelist are omitted from the result — // universe (latest active world estimate pricelists — the same source the configurator,
// callers must treat a missing key as "no category" and skip that lot. // BOM and pricing tab use, see bible-local/decisions/2026-07-24-component-universe-world-union.md).
func ResolveLotCategories(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) { // 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 { if local == nil {
return nil, fmt.Errorf("local db is nil") return nil, fmt.Errorf("local db is nil")
} }
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames) cats, err := local.GetLocalComponentCategoriesByLotNames(lotNames)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+51 -2
View File
@@ -20,6 +20,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
Source: "estimate", Source: "estimate",
Version: "S-2026-02-11-001", Version: "S-2026-02-11-001",
Name: "test", Name: "test",
IsActive: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
SyncedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil { }); err != nil {
@@ -35,7 +36,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
t.Fatalf("save local items: %v", err) 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 { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -76,7 +77,7 @@ func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
t.Fatalf("save items: %v", err) 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 { if err != nil {
t.Fatalf("unexpected error: %v", err) 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) { func TestGroupForLotCategory(t *testing.T) {
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU { if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
t.Fatalf("expected cpu -> GroupCPU") t.Fatalf("expected cpu -> GroupCPU")
+171 -126
View File
@@ -12,14 +12,24 @@ import (
type BuildOptions struct { type BuildOptions struct {
ServerModel string ServerModel string
ServerPricelist *uint
} }
type BuildResult struct { type BuildResult struct {
Article string Article string
Segments []ResultSegment
Warnings []string 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 ( var (
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`) reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`) reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
@@ -33,6 +43,17 @@ var (
type namedSeg struct { type namedSeg struct {
group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT" group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT"
value string 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) { 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 == "" { if model == "" {
return BuildResult{}, fmt.Errorf("server_model required") 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)) lotNames := make([]string, 0, len(items))
for _, it := range items { for _, it := range items {
lotNames = append(lotNames, it.LotName) lotNames = append(lotNames, it.LotName)
} }
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 { cats, err := ResolveLotCategories(local, lotNames)
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
}
cats, err := ResolveLotCategories(local, *opts.ServerPricelist, lotNames)
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" { for _, sb := range []struct {
segs = append(segs, namedSeg{"CPU", cpuSeg}) 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})
} }
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})
} }
if supportSeg := buildSupportSegment(items); supportSeg != "" { if supportSeg := buildSupportSegment(items); supportSeg != "" {
segs = append(segs, namedSeg{"SUPPORT", supportSeg}) segs = append(segs, namedSeg{group: "SUPPORT", value: supportSeg})
} }
article := strings.Join(namedSegsValues(segs), "-") article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) > 80 { if len([]rune(article)) > 80 {
article = compressArticle(segs) segs = compressArticle(segs)
article = strings.Join(namedSegsValues(segs), "-")
warnings = append(warnings, "compressed") warnings = append(warnings, "compressed")
} }
if len([]rune(article)) > 80 { if len([]rune(article)) > 80 {
return BuildResult{}, fmt.Errorf("article_overflow") 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 { func namedSegsValues(segs []namedSeg) []string {
@@ -145,38 +154,43 @@ func buildSupportSegment(items []models.ConfigItem) string {
return "" return ""
} }
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string { // placeholderToken returns the token used in the article when a lot's spec can't be
type agg struct { // parsed: the lot_category itself (never a bare "UNK"), uppercased.
qty int 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 { for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName]) group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupCPU { if !ok || group != GroupCPU {
continue continue
} }
model := parseCPUModel(it.LotName) model, parsed := parseCPUModel(it.LotName)
if model == "" { if !parsed {
model = "UNK" 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] += it.Quantity
models[model] = &agg{}
}
models[model].qty += it.Quantity
} }
if len(models) == 0 { if len(models) == 0 {
return "" return res
} }
parts := make([]string, 0, len(models)) res.value = joinQtyTokens(models)
for model, a := range models { return res
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
} }
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) { func buildMemSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
totalGiB := 0 totalGiB := 0
res := segmentResult{}
unparsed := 0
for _, it := range items { for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName]) group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupMEM { if !ok || group != GroupMEM {
@@ -184,21 +198,31 @@ func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string,
} }
per := parseMemGiB(it.LotName) per := parseMemGiB(it.LotName)
if per <= 0 { 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 totalGiB += per * it.Quantity
} }
if totalGiB == 0 { parts := make([]string, 0, 2)
return "", "" if totalGiB > 0 {
}
if totalGiB%1024 == 0 { if totalGiB%1024 == 0 {
return fmt.Sprintf("%dT", totalGiB/1024), "" parts = append(parts, fmt.Sprintf("%dT", totalGiB/1024))
} else {
parts = append(parts, fmt.Sprintf("%dG", totalGiB))
} }
return 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) string { func buildGPUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
models := map[string]int{} models := map[string]int{}
res := segmentResult{}
for _, it := range items { for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName]) group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupGPU { 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_") { if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
continue continue
} }
model := parseGPUModel(it.LotName) model, parsed := parseGPUModel(it.LotName)
if model == "" { if !parsed {
model = "UNK" 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 models[model] += it.Quantity
} }
if len(models) == 0 { if len(models) == 0 {
return "" return res
} }
parts := make([]string, 0, len(models)) res.value = joinQtyTokens(models)
for model, qty := range models { return res
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
} }
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) { func buildDiskSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
type key struct { type key struct {
t string t string
c string c string
} }
groupQty := map[key]int{} groupQty := map[key]int{}
warn := "" res := segmentResult{}
for _, it := range items { for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName]) group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupDISK { if !ok || group != GroupDISK {
@@ -238,14 +260,14 @@ func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string
} }
capToken := parseCapacity(it.LotName) capToken := parseCapacity(it.LotName)
if capToken == "" { 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) typeCode := diskTypeCode(cats[it.LotName], it.LotName)
k := key{t: typeCode, c: capToken} groupQty[key{t: typeCode, c: capToken}] += it.Quantity
groupQty[k] += it.Quantity
} }
if len(groupQty) == 0 { if len(groupQty) == 0 {
return "", "" return res
} }
parts := make([]string, 0, len(groupQty)) parts := make([]string, 0, len(groupQty))
for k, qty := range groupQty { for k, qty := range groupQty {
@@ -256,23 +278,25 @@ func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string
} }
} }
sort.Strings(parts) 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) { func buildNetSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "UNKNET", "net_unknown") return buildProfileSegment(items, cats, GroupNET, parsePortSpeed, "NET")
} }
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) { func buildPSUSegment(items []models.ConfigItem, cats map[string]string) segmentResult {
return buildProfileSegment(items, cats, GroupPSU, parseWatts, "UNKPSU", "psu_unknown") return buildProfileSegment(items, cats, GroupPSU, parseWatts, "PSU")
} }
// buildProfileSegment groups items of the given category group by a profile token // 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 // parsed from their lot name (e.g. port speed, wattage rating). When a lot's profile
// unknownToken and warnCode when a lot's profile can't be determined. // can't be determined it falls back to the category placeholder and flags the segment
func buildProfileSegment(items []models.ConfigItem, cats map[string]string, group Group, parseProfile func(string) string, unknownToken, warnCode string) (string, string) { // 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{} groupQty := map[string]int{}
warn := "" res := segmentResult{}
for _, it := range items { for _, it := range items {
g, ok := GroupForLotCategory(cats[it.LotName]) g, ok := GroupForLotCategory(cats[it.LotName])
if !ok || g != group { if !ok || g != group {
@@ -280,20 +304,27 @@ func buildProfileSegment(items []models.ConfigItem, cats map[string]string, grou
} }
profile := parseProfile(it.LotName) profile := parseProfile(it.LotName)
if profile == "" { if profile == "" {
profile = unknownToken profile = placeholderToken(groupLabel)
warn = warnCode res.degraded = true
res.warnings = append(res.warnings, fmt.Sprintf("%s: не распознан профиль LOT %q — в артикул записана категория %q", groupLabel, it.LotName, profile))
} }
groupQty[profile] += it.Quantity groupQty[profile] += it.Quantity
} }
if len(groupQty) == 0 { if len(groupQty) == 0 {
return "", "" return res
} }
parts := make([]string, 0, len(groupQty)) res.value = joinQtyTokens(groupQty)
for profile, qty := range groupQty { return res
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile)) }
// 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) sort.Strings(parts)
return strings.Join(parts, "+"), warn return strings.Join(parts, "+")
} }
func normalizeModelToken(lotName string) string { func normalizeModelToken(lotName string) string {
@@ -305,18 +336,27 @@ func normalizeModelToken(lotName string) string {
return strings.ToUpper(strings.TrimSpace(token)) 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, "_") parts := strings.Split(lotName, "_")
if len(parts) >= 2 { if len(parts) >= 2 {
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1])) last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
if last != "" { 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) upper := strings.ToUpper(lotName)
if idx := strings.Index(upper, "GPU_"); idx >= 0 { if idx := strings.Index(upper, "GPU_"); idx >= 0 {
upper = upper[idx+4:] upper = upper[idx+4:]
@@ -347,7 +387,7 @@ func parseGPUModel(lotName string) string {
} }
if model == "" && i > 0 { if model == "" && i > 0 {
model = p model = p
} else if model != "" && numSuffix == "" && isNumeric(p) { } else if model != "" && numSuffix == "" && isModelNumber(p) {
numSuffix = p numSuffix = p
} }
} }
@@ -357,20 +397,24 @@ func parseGPUModel(lotName string) string {
full = model + numSuffix full = model + numSuffix
} }
if full != "" && mem != "" { if full != "" && mem != "" {
return full + "_" + mem return full + "_" + mem, true
} }
if full != "" { if full != "" {
return full return full, true
} }
return normalizeModelToken(lotName) return normalizeModelToken(lotName), false
} }
func isNumeric(s string) bool { // isModelNumber reports whether s looks like a GPU/accelerator model number token:
if s == "" { // 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 return false
} }
for _, r := range s { for _, r := range s {
if r < '0' || r > '9' { if (r < '0' || r > '9') && (r < 'A' || r > 'Z') {
return false return false
} }
} }
@@ -478,42 +522,43 @@ func atoi(v string) int {
return n 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 { if len(segs) == 0 {
return "" return segs
}
fits := func() bool {
return len([]rune(strings.Join(namedSegsValues(segs), "-"))) <= 80
} }
for i, s := range segs { for i, s := range segs {
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G") segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
} }
article := strings.Join(namedSegsValues(segs), "-") if fits() {
if len([]rune(article)) <= 80 { return segs
return article
} }
// 1) remove PSU // 1) remove PSU
if i := findSegGroup(segs, "PSU"); i >= 0 { if i := findSegGroup(segs, "PSU"); i >= 0 {
segs = append(segs[:i], segs[i+1:]...) segs = append(segs[:i], segs[i+1:]...)
article = strings.Join(namedSegsValues(segs), "-") if fits() {
if len([]rune(article)) <= 80 { return segs
return article
} }
} }
// 2) compress NET/HBA/HCA // 2) compress NET/HBA/HCA
if i := findSegGroup(segs, "NET"); i >= 0 { if i := findSegGroup(segs, "NET"); i >= 0 {
segs[i].value = compressNetSegment(segs[i].value) segs[i].value = compressNetSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-") if fits() {
if len([]rune(article)) <= 80 { return segs
return article
} }
} }
// 3) compress DISK // 3) compress DISK
if i := findSegGroup(segs, "DISK"); i >= 0 { if i := findSegGroup(segs, "DISK"); i >= 0 {
segs[i].value = compressDiskSegment(segs[i].value) segs[i].value = compressDiskSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-") if fits() {
if len([]rune(article)) <= 80 { return segs
return article
} }
} }
@@ -521,7 +566,7 @@ func compressArticle(segs []namedSeg) string {
if i := findSegGroup(segs, "GPU"); i >= 0 { if i := findSegGroup(segs, "GPU"); i >= 0 {
segs[i].value = compressGPUSegment(segs[i].value) segs[i].value = compressGPUSegment(segs[i].value)
} }
return strings.Join(namedSegsValues(segs), "-") return segs
} }
func compressNetSegment(seg string) string { func compressNetSegment(seg string) string {
+66 -2
View File
@@ -48,7 +48,6 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
} }
result, err := Build(local, items, BuildOptions{ result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11", ServerModel: "DL380GEN11",
ServerPricelist: &localPL.ServerID,
}) })
if err != nil { if err != nil {
t.Fatalf("build article: %v", err) t.Fatalf("build article: %v", err)
@@ -119,7 +118,6 @@ func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
} }
result, err := Build(local, items, BuildOptions{ result, err := Build(local, items, BuildOptions{
ServerModel: "NF5280M6", ServerModel: "NF5280M6",
ServerPricelist: &localPL.ServerID,
}) })
if err != nil { if err != nil {
t.Fatalf("build article: %v", err) t.Fatalf("build article: %v", err)
@@ -140,3 +138,69 @@ func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
func contains(s, sub string) bool { func contains(s, sub string) bool {
return strings.Contains(s, sub) 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)
}
}
+2
View File
@@ -40,5 +40,7 @@ type ArticlePreviewRequest struct {
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
ServerModel string `json:"server_model"` ServerModel string `json:"server_model"`
SupportCode string `json:"support_code,omitempty"` SupportCode string `json:"support_code,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"` PricelistID *uint `json:"pricelist_id,omitempty"`
} }
+20 -8
View File
@@ -71,12 +71,17 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
return nil, articleErr return nil, articleErr
} }
req.Article = articleResult.Article 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() total := req.Items.Total()
@@ -167,12 +172,17 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
return nil, articleErr return nil, articleErr
} }
req.Article = articleResult.Article 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() 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. // BuildArticlePreview generates server article based on current items and server_model/support_code.
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) { func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
pricelistID, err := s.resolvePricelistID(req.PricelistID) // Categories for the article come from the component universe (world estimate),
if err != nil { // not from a specific pricelist, so req.PricelistID is no longer needed here.
return article.BuildResult{}, err
}
return article.Build(s.localDB, req.Items, article.BuildOptions{ return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
}) })
} }
@@ -528,12 +535,17 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
if strings.TrimSpace(req.ServerModel) != "" { if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{ articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
ServerPricelist: pricelistID,
}) })
if articleErr != nil { if articleErr != nil {
return nil, articleErr return nil, articleErr
} }
req.Article = articleResult.Article 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() total := req.Items.Total()
+28 -1
View File
@@ -2537,13 +2537,40 @@ async function previewArticle() {
} }
const data = await resp.json(); const data = await resp.json();
currentArticle = data.article || ''; currentArticle = data.article || '';
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —'; renderArticleDisplay(el, currentArticle, data.segments, data.warnings);
} catch(e) { } catch(e) {
currentArticle = ''; currentArticle = '';
el.textContent = 'Артикул: —'; el.textContent = 'Артикул: —';
} }
} }
// renderArticleDisplay shows the article, colouring any segment the generator could
// not fully parse (Recognized === false — its token is a category placeholder, not a
// real spec) and listing the warnings that name the offending LOTs.
function renderArticleDisplay(el, article, segments, warnings) {
if (!article) { el.textContent = 'Артикул: —'; return; }
let body;
if (Array.isArray(segments) && segments.length) {
body = segments.map(s => {
const t = escapeHtml(s.text);
return s.recognized === false
? '<span class="text-amber-700 bg-amber-100 rounded px-0.5" title="не распознано — записана категория">' + t + '</span>'
: t;
}).join('-');
} else {
body = escapeHtml(article);
}
let html = 'Артикул: ' + body;
if (Array.isArray(warnings) && warnings.length) {
const list = warnings.filter(w => w !== 'compressed')
.map(w => '<li>' + escapeHtml(w) + '</li>').join('');
if (list) {
html += '<ul class="mt-1 text-xs text-amber-700 list-disc list-inside font-sans">' + list + '</ul>';
}
}
el.innerHTML = html;
}
function getCurrentArticle() { function getCurrentArticle() {
return currentArticle || ''; return currentArticle || '';
} }