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
+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)
}
}