diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 393ac23..e0dbfad 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -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. diff --git a/bible-local/decisions/2026-09-01-article-category-from-component-universe.md b/bible-local/decisions/2026-09-01-article-category-from-component-universe.md new file mode 100644 index 0000000..d7eb257 --- /dev/null +++ b/bible-local/decisions/2026-09-01-article-category-from-component-universe.md @@ -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`. diff --git a/bible-local/decisions/2026-09-01-article-degraded-token-visibility.md b/bible-local/decisions/2026-09-01-article-degraded-token-visibility.md new file mode 100644 index 0000000..c8f9974 --- /dev/null +++ b/bible-local/decisions/2026-09-01-article-degraded-token-visibility.md @@ -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`. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 5539d5e..c5099a5 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -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, }) }) diff --git a/internal/article/categories.go b/internal/article/categories.go index 9f22d93..b24d653 100644 --- a/internal/article/categories.go +++ b/internal/article/categories.go @@ -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 } diff --git a/internal/article/categories_test.go b/internal/article/categories_test.go index 9d21cd2..bdcc8a0 100644 --- a/internal/article/categories_test.go +++ b/internal/article/categories_test.go @@ -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") diff --git a/internal/article/generator.go b/internal/article/generator.go index e0b2a24..f17aea0 100644 --- a/internal/article/generator.go +++ b/internal/article/generator.go @@ -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 { diff --git a/internal/article/generator_test.go b/internal/article/generator_test.go index 311b15d..bca7769 100644 --- a/internal/article/generator_test.go +++ b/internal/article/generator_test.go @@ -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) + } +} diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 009f490..c963a61 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -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"` } diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index b28dae7..2e1de92 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -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() diff --git a/web/templates/index.html b/web/templates/index.html index cc91bc9..0a3adab 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -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 + ? '' + t + '' + : t; + }).join('-'); + } else { + body = escapeHtml(article); + } + let html = 'Артикул: ' + body; + if (Array.isArray(warnings) && warnings.length) { + const list = warnings.filter(w => w !== 'compressed') + .map(w => '