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;
- `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
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{
"article": result.Article,
"segments": result.Segments,
"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
// 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")
+171 -126
View File
@@ -12,14 +12,24 @@ import (
type BuildOptions struct {
ServerModel string
ServerPricelist *uint
}
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})
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})
}
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 != "" {
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 {
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{}
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 {
+66 -2
View File
@@ -48,7 +48,6 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
}
result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11",
ServerPricelist: &localPL.ServerID,
})
if err != nil {
t.Fatalf("build article: %v", err)
@@ -119,7 +118,6 @@ func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
}
result, err := Build(local, items, BuildOptions{
ServerModel: "NF5280M6",
ServerPricelist: &localPL.ServerID,
})
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)
}
}
+2
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 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"`
}
+20 -8
View File
@@ -71,12 +71,17 @@ 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,
})
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()
@@ -167,12 +172,17 @@ 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,
})
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,
})
}
@@ -528,12 +535,17 @@ 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,
})
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()
+28 -1
View File
@@ -2537,13 +2537,40 @@ async function previewArticle() {
}
const data = await resp.json();
currentArticle = data.article || '';
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
renderArticleDisplay(el, currentArticle, data.segments, data.warnings);
} catch(e) {
currentArticle = '';
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() {
return currentArticle || '';
}