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:
co-authored by
Claude Sonnet 5
parent
ecef030699
commit
d7d4ea74b6
@@ -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`.
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
@@ -35,7 +36,7 @@ func TestResolveLotCategories_MissingLotOmitted(t *testing.T) {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
|
||||
cats, err := ResolveLotCategories(local, []string{"CPU_A", "UNKNOWN"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -76,7 +77,7 @@ func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
|
||||
t.Fatalf("save items: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategories(local, 1, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
|
||||
cats, err := ResolveLotCategories(local, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -91,6 +92,54 @@ func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLotCategories_WorldOnlyLot covers a LOT that exists only in the world
|
||||
// pricelist (never added to estimate) but is a legitimate cart member. Its real
|
||||
// lot_category must be resolved from the component universe, not dropped.
|
||||
func TestResolveLotCategories_WorldOnlyLot(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
saved := func(serverID uint, source string, items []localdb.LocalPricelistItem) {
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverID, Source: source, Version: source + "-v1", Name: source,
|
||||
IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save %s pricelist: %v", source, err)
|
||||
}
|
||||
pl, err := local.GetLocalPricelistByServerID(serverID)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s pricelist: %v", source, err)
|
||||
}
|
||||
for i := range items {
|
||||
items[i].PricelistID = pl.ID
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems(items); err != nil {
|
||||
t.Fatalf("save %s items: %v", source, err)
|
||||
}
|
||||
}
|
||||
|
||||
saved(1, "estimate", []localdb.LocalPricelistItem{
|
||||
{LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
|
||||
})
|
||||
saved(2, "world", []localdb.LocalPricelistItem{
|
||||
{LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
|
||||
{LotName: "GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE", LotCategory: "GPU", Price: 9900},
|
||||
})
|
||||
|
||||
cats, err := ResolveLotCategories(local, []string{
|
||||
"CPU_INTEL_8358", "GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cats["GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE"] != "GPU" {
|
||||
t.Fatalf("world-only LOT category not resolved: %q", cats["GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupForLotCategory(t *testing.T) {
|
||||
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||
t.Fatalf("expected cpu -> GroupCPU")
|
||||
|
||||
+173
-128
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user