diff --git a/bible-local/03-database.md b/bible-local/03-database.md
index 73277b2..f52e733 100644
--- a/bible-local/03-database.md
+++ b/bible-local/03-database.md
@@ -261,7 +261,7 @@ PK: lot_name
| price | decimal(12,2) NOT NULL | |
| price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. |
-`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`, still scoped to the currently selected pricelist. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
+`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items` — **keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need.
diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md
index 6fce2d3..1886980 100644
--- a/bible-local/09-vendor-spec.md
+++ b/bible-local/09-vendor-spec.md
@@ -25,6 +25,33 @@ Rules:
- QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`.
+## Split partnumbers (HPE option codes)
+
+HPE specs carry the partnumber in two columns: base PN plus an option code
+(`P52534-B21` + `B19`). The canonical partnumber is the concatenation
+`P52534-B21#B19`, and it is what gets compared against the partnumber book.
+
+Rules:
+- the import grid has a `PN code` column type in addition to `P/N`; it is optional
+ and at most one column may carry it;
+- an empty code cell keeps the bare PN — no trailing `#`;
+- the composed value is stored in `vendor_partnumber`. There is **no separate
+ `pn_code` field**: no DDL and no new JSON key, and the stored string is exactly
+ the string the resolver looks up;
+- the column layout of the import grid is not persisted. On load the grid is
+ re-derived from `vendor_spec`, and the `PN code` column reappears only when at
+ least one stored partnumber contains `#`, split on the first `#`.
+
+## Re-resolving an existing BOM
+
+Opening a configuration does **not** re-resolve its BOM: `loadVendorSpec` renders the
+`lot_mappings[]` frozen at save time, so a configuration keeps the mapping it was
+saved with even after the partnumber book changes.
+
+Book entries added later are picked up only through the `Пересопоставить` button,
+which calls the resolve endpoint for every row. Book matches win over the stored
+mapping (resolver step 1 beats step 2); rows the book does not know keep theirs.
+
## Partnumber books
Partnumber books are pull-only snapshots from PriceForge.
diff --git a/bible-local/decisions/2026-07-24-component-universe-world-union.md b/bible-local/decisions/2026-07-24-component-universe-world-union.md
new file mode 100644
index 0000000..70f87f7
--- /dev/null
+++ b/bible-local/decisions/2026-07-24-component-universe-world-union.md
@@ -0,0 +1,57 @@
+# Decision: the component universe is `world` ∪ `estimate`, not `estimate` alone
+
+**Date:** 2026-07-24
+**Status:** active
+
+## Context
+
+Every component read path in `internal/localdb/components.go` was scoped to the latest
+active `estimate` pricelist. That pricelist is therefore what defined the set of LOTs the
+configurator considers to exist, and the frontend used it as its validation list
+(`_bomLotValid` in `index.html`, fed by `/api/components`).
+
+That silently broke BOM mappings. The resolver matches a vendor partnumber against the
+partnumber book, which knows nothing about pricelists, so it legitimately returns LOTs the
+estimate pricelist does not carry. The frontend then displayed such a row as mapped —
+`_getRowBaseLot` does not validate `resolved_lot` — and `applyBOMToEstimate` pushed the LOT
+into the cart, but `_getRowCanonicalLotMappings` filtered it out through `_bomLotValid`, so
+the mapping vanished on save. Result: the LOT sat in the cart while its BOM row showed
+"н/д", and the pricing tab listed it at the bottom as an orphan with an empty vendor PN.
+
+Prices for such LOTs were already available — see
+[2026-07-10-world-pricelist-fallback.md](2026-07-10-world-pricelist-fallback.md) — only
+membership was missing.
+
+## Decision
+
+`componentUniverse()` in `internal/localdb/components.go` is the single source of the LOT
+set, and every component read path goes through it: `ListComponents`,
+`SearchLocalComponents`, `SearchLocalComponentsByCategory`, `GetLocalComponent`,
+`GetLocalComponentCategories`, `GetLocalComponentCategoriesByLotNames`, `CountComponents`.
+
+- The universe is the latest active `world` pricelist — the widest list available — plus
+ the latest active `estimate` pricelist as a safety net.
+- Deduplication is on `UPPER(lot_name)`. The estimate row wins a collision, so its
+ category, description and `price_quality` stay authoritative and nothing changes for
+ LOTs that were already visible.
+- A world-only row reports `price_quality = 0`, rendering at the red end of the shared
+ scale in `web/static/price-quality.js`. This is the **one** place QF sets that field
+ instead of only reading it (contrast `bible-local/03-database.md`), and it deliberately
+ overrides whatever the external pricing tool wrote on the world row.
+- The helper always returns a derived table (`(?) AS c`), so callers apply their own
+ `Select`/`Count`/`Where` without knowing which pricelists exist.
+- Either pricelist may be absent; only both missing is an error.
+
+On the frontend, `_bomLotPersistable` additionally lets a LOT with
+`resolution_source === 'book'` be persisted into `lot_mappings[]` even when it is in
+neither pricelist. Hand-typed LOTs stay validated against the universe.
+
+## Consequences
+
+- The configurator's pickers, category list and search now surface world-only LOTs. They
+ are selectable and appear in the reddest quality colour.
+- `CountComponents` — the "is there data" check — counts the union.
+- A LOT present in neither pricelist (nothing to price it with) still resolves from the
+ book and now survives a save, instead of being dropped without a message.
+- Covered by `internal/localdb/component_universe_test.go`: union and dedup, estimate
+ winning a collision, forced quality 0, and each single-source / empty case.
diff --git a/internal/localdb/component_universe_test.go b/internal/localdb/component_universe_test.go
new file mode 100644
index 0000000..b61259f
--- /dev/null
+++ b/internal/localdb/component_universe_test.go
@@ -0,0 +1,176 @@
+package localdb
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// seedUniversePricelist stores one active pricelist of the given source with the
+// supplied items and returns its local id.
+func seedUniversePricelist(t *testing.T, local *LocalDB, serverID uint, source, version string, created time.Time, items []LocalPricelistItem) uint {
+ t.Helper()
+ pl := &LocalPricelist{
+ ServerID: serverID,
+ Source: source,
+ Version: version,
+ Name: version,
+ CreatedAt: created,
+ SyncedAt: created,
+ IsActive: true,
+ }
+ if err := local.SaveLocalPricelist(pl); err != nil {
+ t.Fatalf("save %s pricelist: %v", source, err)
+ }
+ stored, err := local.GetLocalPricelistByServerID(serverID)
+ if err != nil {
+ t.Fatalf("load %s pricelist: %v", source, err)
+ }
+ for i := range items {
+ items[i].PricelistID = stored.ID
+ }
+ if err := local.SaveLocalPricelistItems(items); err != nil {
+ t.Fatalf("save %s items: %v", source, err)
+ }
+ return stored.ID
+}
+
+func quality(v int) *int { return &v }
+
+func newUniverseDB(t *testing.T, name string) *LocalDB {
+ t.Helper()
+ local, err := New(filepath.Join(t.TempDir(), name))
+ if err != nil {
+ t.Fatalf("open localdb: %v", err)
+ }
+ t.Cleanup(func() { _ = local.Close() })
+ return local
+}
+
+// The component universe is world ∪ estimate: a LOT the estimate pricelist does not
+// carry must still be selectable, otherwise BOM lot_mappings resolved from the
+// partnumber book get silently dropped on save.
+func TestComponentUniverseUnionsWorldAndEstimate(t *testing.T) {
+ local := newUniverseDB(t, "universe_union.db")
+ base := time.Now().Add(-time.Minute)
+
+ seedUniversePricelist(t, local, 1, "estimate", "E-1", base, []LocalPricelistItem{
+ {LotName: "CPU_A", LotCategory: "CPU", LotDescription: "from estimate", Price: 100, PriceQuality: quality(8)},
+ {LotName: "SHARED_LOT", LotCategory: "MEM", LotDescription: "from estimate", Price: 50, PriceQuality: quality(9)},
+ })
+ seedUniversePricelist(t, local, 2, "world", "W-1", base, []LocalPricelistItem{
+ {LotName: "SHARED_LOT", LotCategory: "WRONG", LotDescription: "from world", Price: 55, PriceQuality: quality(1)},
+ {LotName: "NIC_WORLD_ONLY", LotCategory: "NIC", LotDescription: "from world", Price: 70, PriceQuality: quality(7)},
+ })
+
+ components, total, err := local.ListComponents(ComponentFilter{}, 0, 100)
+ if err != nil {
+ t.Fatalf("ListComponents: %v", err)
+ }
+ if total != 3 {
+ t.Fatalf("expected 3 components (dedup on SHARED_LOT), got %d", total)
+ }
+
+ byLot := make(map[string]LocalComponent, len(components))
+ for _, c := range components {
+ if _, dup := byLot[c.LotName]; dup {
+ t.Fatalf("duplicate lot in universe: %s", c.LotName)
+ }
+ byLot[c.LotName] = c
+ }
+
+ worldOnly, ok := byLot["NIC_WORLD_ONLY"]
+ if !ok {
+ t.Fatal("world-only LOT missing from the component universe")
+ }
+ if worldOnly.PriceQuality == nil || *worldOnly.PriceQuality != 0 {
+ t.Fatalf("world-only LOT must report price_quality 0, got %v", worldOnly.PriceQuality)
+ }
+
+ // The estimate row wins a collision, so its metadata stays authoritative.
+ shared := byLot["SHARED_LOT"]
+ if shared.Category != "MEM" || shared.LotDescription != "from estimate" {
+ t.Fatalf("estimate row must win a collision, got category=%q description=%q", shared.Category, shared.LotDescription)
+ }
+ if shared.PriceQuality == nil || *shared.PriceQuality != 9 {
+ t.Fatalf("estimate price_quality must survive a collision, got %v", shared.PriceQuality)
+ }
+
+ if got := local.CountComponents(); got != 3 {
+ t.Fatalf("CountComponents: expected 3, got %d", got)
+ }
+
+ single, err := local.GetLocalComponent("nic_world_only")
+ if err != nil {
+ t.Fatalf("GetLocalComponent for a world-only LOT: %v", err)
+ }
+ if single.LotName != "NIC_WORLD_ONLY" {
+ t.Fatalf("GetLocalComponent returned %q", single.LotName)
+ }
+
+ categories, err := local.GetLocalComponentCategories()
+ if err != nil {
+ t.Fatalf("GetLocalComponentCategories: %v", err)
+ }
+ if len(categories) != 3 {
+ t.Fatalf("expected CPU/MEM/NIC, got %v", categories)
+ }
+
+ cats, err := local.GetLocalComponentCategoriesByLotNames([]string{"NIC_WORLD_ONLY", "SHARED_LOT"})
+ if err != nil {
+ t.Fatalf("GetLocalComponentCategoriesByLotNames: %v", err)
+ }
+ if cats["NIC_WORLD_ONLY"] != "NIC" || cats["SHARED_LOT"] != "MEM" {
+ t.Fatalf("unexpected categories: %v", cats)
+ }
+
+ found, err := local.SearchLocalComponents("world_only", 10)
+ if err != nil || len(found) != 1 {
+ t.Fatalf("SearchLocalComponents: got %d rows, err=%v", len(found), err)
+ }
+
+ inCategory, err := local.SearchLocalComponentsByCategory("NIC", "", 10)
+ if err != nil || len(inCategory) != 1 {
+ t.Fatalf("SearchLocalComponentsByCategory: got %d rows, err=%v", len(inCategory), err)
+ }
+}
+
+// Only one of the two pricelists existing must not break the universe.
+func TestComponentUniverseWithSingleSource(t *testing.T) {
+ t.Run("estimate only", func(t *testing.T) {
+ local := newUniverseDB(t, "universe_estimate_only.db")
+ seedUniversePricelist(t, local, 1, "estimate", "E-1", time.Now(), []LocalPricelistItem{
+ {LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
+ })
+ if got := local.CountComponents(); got != 1 {
+ t.Fatalf("expected 1 component, got %d", got)
+ }
+ })
+
+ t.Run("world only", func(t *testing.T) {
+ local := newUniverseDB(t, "universe_world_only.db")
+ seedUniversePricelist(t, local, 2, "world", "W-1", time.Now(), []LocalPricelistItem{
+ {LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
+ })
+ components, _, err := local.ListComponents(ComponentFilter{}, 0, 10)
+ if err != nil {
+ t.Fatalf("ListComponents: %v", err)
+ }
+ if len(components) != 1 {
+ t.Fatalf("expected 1 component, got %d", len(components))
+ }
+ if components[0].PriceQuality == nil || *components[0].PriceQuality != 0 {
+ t.Fatalf("world-only LOT must report price_quality 0, got %v", components[0].PriceQuality)
+ }
+ })
+
+ t.Run("neither", func(t *testing.T) {
+ local := newUniverseDB(t, "universe_empty.db")
+ if _, _, err := local.ListComponents(ComponentFilter{}, 0, 10); err == nil {
+ t.Fatal("expected an error when no estimate and no world pricelist exists")
+ }
+ if got := local.CountComponents(); got != 0 {
+ t.Fatalf("expected 0, got %d", got)
+ }
+ })
+}
diff --git a/internal/localdb/components.go b/internal/localdb/components.go
index e0e5078..a2be397 100644
--- a/internal/localdb/components.go
+++ b/internal/localdb/components.go
@@ -4,6 +4,8 @@ import (
"fmt"
"strings"
"time"
+
+ "gorm.io/gorm"
)
// ComponentFilter for searching with filters
@@ -40,6 +42,60 @@ func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
return id, nil
}
+// componentUniverse returns a query over the set of LOTs the configurator is allowed
+// to choose from. That set is the latest active `world` pricelist — the widest list we
+// have — plus, as a safety net, the latest active `estimate` pricelist, deduplicated by
+// UPPER(lot_name).
+//
+// Scoping this to `estimate` alone used to silently drop BOM lot_mappings: the resolver
+// matches a partnumber against the book, which knows nothing about pricelists, and the
+// frontend then rejected any LOT the estimate pricelist did not carry. Prices for
+// world-only LOTs already come from the world fallback (see
+// decisions/2026-07-10-world-pricelist-fallback.md).
+//
+// The estimate row wins a collision, so its category/description/price_quality stay
+// authoritative. A world-only row reports price_quality 0 so it renders at the red end of
+// the quality scale — the one place QF sets this field rather than only reading it.
+// Either pricelist may be absent; only both missing is an error.
+func (l *LocalDB) componentUniverse() (*gorm.DB, error) {
+ estimateID, estimateErr := l.latestActivePricelistID("estimate")
+ worldID, worldErr := l.latestActivePricelistID("world")
+ if estimateErr != nil && worldErr != nil {
+ return nil, fmt.Errorf("no active estimate or world pricelist")
+ }
+
+ // Always a derived table, so every caller can apply its own Select/Count/Where
+ // uniformly and none of them has to know which pricelists actually exist.
+ const estimateSelect = `
+ SELECT lot_name, lot_category, lot_description, price_quality
+ FROM local_pricelist_items
+ WHERE pricelist_id = ?`
+ const worldSelect = `
+ SELECT w.lot_name, w.lot_category, w.lot_description, 0 AS price_quality
+ FROM local_pricelist_items w
+ WHERE w.pricelist_id = ?`
+ const worldOnlyTail = `
+ AND NOT EXISTS (
+ SELECT 1 FROM local_pricelist_items e
+ WHERE e.pricelist_id = ?
+ AND UPPER(e.lot_name) = UPPER(w.lot_name)
+ )`
+
+ var inner *gorm.DB
+ switch {
+ case worldErr != nil:
+ inner = l.db.Raw(estimateSelect, estimateID)
+ case estimateErr != nil:
+ inner = l.db.Raw(worldSelect, worldID)
+ default:
+ inner = l.db.Raw(
+ estimateSelect+"\nUNION ALL"+worldSelect+worldOnlyTail,
+ estimateID, worldID, estimateID)
+ }
+
+ return l.db.Table("(?) AS c", inner), nil
+}
+
// pricelistItemRow is used for scanning rows from local_pricelist_items.
type pricelistItemRow struct {
LotName string `gorm:"column:lot_name"`
@@ -57,25 +113,23 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent {
}
}
-// SearchLocalComponents searches components in the latest active estimate
-// pricelist by lot_name.
+// SearchLocalComponents searches the component universe by lot_name.
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return nil, err
}
- db := l.db.Table("local_pricelist_items").
- Where("pricelist_id = ?", pricelistID)
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
- if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
+ if err := db.Select("lot_name, lot_category, lot_description, price_quality").
+ Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
@@ -85,25 +139,24 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone
return components, nil
}
-// SearchLocalComponentsByCategory searches components in the latest active
-// estimate pricelist filtered by category.
+// SearchLocalComponentsByCategory searches the component universe filtered by category.
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return nil, err
}
- db := l.db.Table("local_pricelist_items").
- Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
+ db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(category))
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
- if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
+ if err := db.Select("lot_name, lot_category, lot_description, price_quality").
+ Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
@@ -113,17 +166,14 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit
return components, nil
}
-// ListComponents returns components from the latest active estimate pricelist
-// with optional category/search filtering and pagination.
+// ListComponents returns components from the component universe with optional
+// category/search filtering and pagination.
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return nil, 0, err
}
- db := l.db.Table("local_pricelist_items").
- Where("pricelist_id = ?", pricelistID)
-
if filter.Category != "" {
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
}
@@ -147,18 +197,17 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
return components, total, nil
}
-// GetLocalComponent returns a single component by lot_name from the latest
-// active estimate pricelist.
+// GetLocalComponent returns a single component by lot_name from the component universe.
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return nil, err
}
var row pricelistItemRow
- if err := l.db.Table("local_pricelist_items").
+ if err := db.
Select("lot_name, lot_category, lot_description, price_quality").
- Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
+ Where("UPPER(lot_name) = ?", strings.ToUpper(lotName)).
First(&row).Error; err != nil {
return nil, err
}
@@ -167,13 +216,13 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
}
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
-// from the latest active estimate pricelist.
+// from the component universe.
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return result, nil
}
@@ -187,9 +236,9 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
upperToOrig[u] = n
}
var rows []pricelistItemRow
- if err := l.db.Table("local_pricelist_items").
+ if err := db.
Select("lot_name, lot_category").
- Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
+ Where("UPPER(lot_name) IN ?", upper).
Scan(&rows).Error; err != nil {
return nil, err
}
@@ -203,17 +252,16 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
return result, nil
}
-// GetLocalComponentCategories returns distinct categories from the latest
-// active estimate pricelist.
+// GetLocalComponentCategories returns distinct categories from the component universe.
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return nil, err
}
var categories []string
- if err := l.db.Table("local_pricelist_items").
- Where("pricelist_id = ? AND lot_category != ''", pricelistID).
+ if err := db.
+ Where("lot_category != ''").
Distinct("lot_category").
Order("lot_category").
Pluck("lot_category", &categories).Error; err != nil {
@@ -222,14 +270,14 @@ func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
return categories, nil
}
-// CountComponents returns the number of distinct lot names in the latest
-// active estimate pricelist (used to check if data is available).
+// CountComponents returns the number of lot names in the component universe
+// (used to check if data is available).
func (l *LocalDB) CountComponents() int64 {
- pricelistID, err := l.latestActivePricelistID("estimate")
+ db, err := l.componentUniverse()
if err != nil {
return 0
}
var count int64
- l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
+ db.Count(&count)
return count
}
diff --git a/web/static/app.css b/web/static/app.css
index 23edae4..a08d7eb 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -7,3 +7,40 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
+
+/* Pricing footer totals: instant hover popup explaining how much of the sum rests on
+ world-pricelist stand-in prices. A native title= tooltip was too slow and too easy to
+ miss on a number the user is meant to distrust. */
+.pricing-total-tip {
+ position: relative;
+ cursor: help;
+}
+
+.pricing-total-tip > .pricing-total-tip__body {
+ visibility: hidden;
+ opacity: 0;
+ position: absolute;
+ right: 0;
+ bottom: calc(100% + 6px);
+ z-index: 30;
+ width: max-content;
+ max-width: 20rem;
+ padding: 0.5rem 0.625rem;
+ border-radius: 0.375rem;
+ background: #1f2937;
+ color: #f9fafb;
+ font-size: 0.75rem;
+ font-weight: 400;
+ line-height: 1.35;
+ text-align: left;
+ white-space: normal;
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
+ transition: opacity 0.1s ease-in;
+ pointer-events: none;
+}
+
+.pricing-total-tip:hover > .pricing-total-tip__body,
+.pricing-total-tip:focus-visible > .pricing-total-tip__body {
+ visibility: visible;
+ opacity: 1;
+}
diff --git a/web/templates/index.html b/web/templates/index.html
index ea1bdbf..e74288c 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -194,6 +194,9 @@
+
@@ -219,26 +222,26 @@
-
PN вендора
-
Описание
-
LOT
-
Кол-во
-
Estimate
-
Склад
-
Конкуренты
-
Ручная цена
+
PN вендора
+
Описание
+
LOT
+
Кол-во
+
Estimate
+
Склад
+
Конкуренты
+
Ручная цена
-
Загрузите BOM во вкладке «BOM»
+
Загрузите BOM во вкладке «BOM»
-
Итого:
-
—
-
—
-
—
-
—
+
Итого:
+
—
+
—
+
—
+
—
@@ -268,26 +271,26 @@
-
PN вендора
-
Описание
-
LOT
-
Кол-во
-
Estimate
-
Склад
-
Конкуренты
-
Ручная цена
+
PN вендора
+
Описание
+
LOT
+
Кол-во
+
Estimate
+
Склад
+
Конкуренты
+
Ручная цена
-
Загрузите BOM во вкладке «BOM»
+
Загрузите BOM во вкладке «BOM»
-
Итого:
-
—
-
—
-
—
-
—
+
Итого:
+
—
+
—
+
—
+
—
@@ -3480,6 +3483,7 @@ let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRow
const BOM_COL_TYPES = [
{ value: 'ignore', label: 'Не использовать' },
{ value: 'pn', label: 'P/N' },
+ { value: 'pn_code', label: 'PN code' },
{ value: 'qty', label: 'Кол-во' },
{ value: 'price', label: 'Цена' },
{ value: 'description', label: 'Описание' }
@@ -3490,6 +3494,7 @@ function _bomRawHeaderWidthClass(colType) {
case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32';
case 'pn': return 'min-w-40';
+ case 'pn_code': return 'w-28 min-w-28';
case 'description': return 'min-w-48';
default: return 'min-w-28';
}
@@ -3499,10 +3504,32 @@ function _bomRawCellWidthClass(colType) {
switch (colType) {
case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32';
+ case 'pn_code': return 'w-28 min-w-28';
default: return '';
}
}
+// HPE specs split the partnumber across two columns (PN + option CODE): the
+// canonical partnumber is "P52534-B21#B19". A row with an empty code column
+// keeps the bare PN.
+function _bomComposePN(cols, idx) {
+ const pn = ((cols[idx.pn[0]] || '') + '').trim();
+ if (!pn) return '';
+ const codeCol = idx.pn_code.length ? idx.pn_code[0] : -1;
+ if (codeCol === -1) return pn;
+ const code = ((cols[codeCol] || '') + '').trim();
+ return code ? `${pn}#${code}` : pn;
+}
+
+// Inverse of _bomComposePN: splits a stored partnumber back into [pn, code].
+// Only the first '#' separates the option code, so a code containing '#' round-trips.
+function _bomSplitPN(vendorPN) {
+ const s = (vendorPN || '').trim();
+ const at = s.indexOf('#');
+ if (at === -1) return [s, ''];
+ return [s.slice(0, at), s.slice(at + 1)];
+}
+
function parsePastePrice(s) {
if (!s) return null;
let v = String(s).replace(/[$\s]/g, '');
@@ -3649,7 +3676,7 @@ async function handleBOMPaste(event) {
function _getBomColumnTypeIndexes() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
- const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
+ const idx = { ignore: [], pn: [], pn_code: [], qty: [], price: [], description: [] };
bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
return idx;
}
@@ -3659,6 +3686,7 @@ function _validateBomColumnTypes() {
const idx = _getBomColumnTypeIndexes();
if (!idx) return { ok: false, message: 'Нет данных BOM' };
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' };
+ if (idx.pn_code.length > 1) return { ok: false, message: 'Можно выбрать только один столбец PN code.' };
if (idx.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
if (idx.description.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Описание.' };
@@ -3801,10 +3829,20 @@ function _getRowBaseLot(row) {
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
return '';
}
+// A LOT resolved from the partnumber book is authoritative even when it is absent
+// from the component list: that list is the active estimate pricelist only
+// (localdb.ListComponents), so a LOT the pricelist does not carry would otherwise be
+// dropped from lot_mappings on save while still reaching the cart via _getRowBaseLot.
+// Hand-typed LOTs stay validated.
+function _bomLotPersistable(row, lot) {
+ if (!lot) return false;
+ return row?.resolution_source === 'book' || _bomLotValid(lot);
+}
+
function _getRowCanonicalLotMappings(row) {
const out = [];
const baseLot = _getRowBaseLot(row);
- if (baseLot && _bomLotValid(baseLot)) {
+ if (baseLot && _bomLotPersistable(row, baseLot)) {
out.push({
lot_name: baseLot,
quantity_per_pn: _getRowLotQtyPerPN(row)
@@ -3980,7 +4018,6 @@ function rebuildBOMRowsFromRaw() {
_setBomUIError('');
const idx = validation.idx;
- const pnCol = idx.pn[0];
const qtyCol = idx.qty[0];
const priceCol = idx.price.length ? idx.price[0] : -1;
const descCol = idx.description.length ? idx.description[0] : -1;
@@ -3988,7 +4025,7 @@ function rebuildBOMRowsFromRaw() {
const nextRows = [];
bomImportRaw.rows.forEach((cols, rowIdx) => {
if (bomImportRaw.ignoredRows?.[rowIdx]) return;
- const pn = ((cols[pnCol] || '') + '').trim();
+ const pn = _bomComposePN(cols, idx);
if (!pn) return;
const qtyRaw = ((cols[qtyCol] || '') + '').trim();
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
@@ -4067,13 +4104,12 @@ async function resolveBOM() {
if (bomImportRaw && bomImportRaw.mode === 'raw') {
const validation = _validateBomColumnTypes();
if (validation.ok) {
- const pnCol = validation.idx.pn[0];
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
ignoredSeen = (bomImportRaw.rows || [])
.map((cols, rowIdx) => ({ cols, rowIdx }))
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
.map(({ cols }) => ({
- partnumber: ((cols[pnCol] || '') + '').trim(),
+ partnumber: _bomComposePN(cols, validation.idx),
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
ignored: true
}))
@@ -4245,6 +4281,21 @@ function renderBOMTable() {
if (currentTopTab === 'pricing') renderPricingTab();
}
+// Manual re-resolve against the current partnumber book. loadVendorSpec deliberately
+// does not re-resolve on open — a configuration keeps the mappings frozen at save time —
+// so this is the way to pick up book entries added since. Book matches win over the
+// stored mapping (resolver step 1 > step 2); rows the book does not know keep theirs.
+async function reresolveBOM() {
+ if (!bomRows.length) return;
+ const before = bomRows.map(r => _getRowBaseLot(r));
+ await resolveBOM();
+ const changed = bomRows.reduce((n, r, i) => n + (_getRowBaseLot(r) !== before[i] ? 1 : 0), 0);
+ const stillUnresolved = bomRows.filter(r => !_getRowBaseLot(r)).length;
+ showToast(changed
+ ? `Пересопоставлено строк: ${changed}. Без LOT: ${stillUnresolved}`
+ : `Изменений нет. Без LOT: ${stillUnresolved}`, changed ? 'success' : 'info');
+}
+
let _resolveBOMTimer = null;
function debouncedResolveBOM() {
clearTimeout(_resolveBOMTimer);
@@ -4416,16 +4467,22 @@ async function loadVendorSpec(configUUID) {
});
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste).
- // Columns: Qty | P/N | Description | Price
+ // Columns: Qty | P/N | Description | Price, plus PN code when any saved
+ // partnumber carries an HPE option code ("P52534-B21#B19"). The column layout
+ // itself is not persisted, so it is re-derived from the stored partnumbers.
+ const hasPNCode = bomRows.some(r => (r.vendor_pn || '').includes('#'));
bomImportRaw = {
mode: 'raw',
- rows: bomRows.map(r => ([
- String(r.quantity ?? 1),
- r.vendor_pn || '',
- r.description || '',
- r.unit_price != null ? String(r.unit_price) : ''
- ])),
- columnTypes: ['qty', 'pn', 'description', 'price'],
+ rows: bomRows.map(r => {
+ const [pn, code] = _bomSplitPN(r.vendor_pn || '');
+ const cells = [String(r.quantity ?? 1), pn];
+ if (hasPNCode) cells.push(code);
+ cells.push(r.description || '', r.unit_price != null ? String(r.unit_price) : '');
+ return cells;
+ }),
+ columnTypes: hasPNCode
+ ? ['qty', 'pn', 'pn_code', 'description', 'price']
+ : ['qty', 'pn', 'description', 'price'],
ignoredRows: {},
rowErrors: {},
uiError: ''
@@ -4659,12 +4716,13 @@ async function renderPricingTab() {
// ─── Populate Buy table ──────────────────────────────────────────────────
tbodyBuy.innerHTML = '';
if (!rowData.length) {
- tbodyBuy.innerHTML = '
`;
tbodySale.appendChild(tr);
});
- document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
- document.getElementById('pricing-total-sale-vendor').textContent = '—';
- _setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length);
- _setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, rowData.length);
+ document.getElementById('pricing-total-sale-vendor').textContent = '—';
+ _setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
+ _setPricingTotal('pricing-total-sale-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
+ _setPricingTotal('pricing-total-sale-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
tfootSale.classList.remove('hidden');
}
@@ -4772,17 +4831,41 @@ async function renderPricingTab() {
// ─── Pricing helpers ─────────────────────────────────────────────────────────
-// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
-function _setPartialTotal(elId, has, total, count, totalRows) {
+// Sets a footer total cell. The total is always red — it is a sum the user must not read
+// as a firm number — and hovering it pops up how much of that sum rests on world-pricelist
+// stand-in prices (see decisions/2026-07-10-world-pricelist-fallback.md), plus the row
+// coverage when some positions have no price at all. The popup is a styled element rather
+// than title=, which appears too late to notice. No asterisk: it wrapped onto its own line
+// and pushed the footer around.
+function _setPricingTotal(elId, has, total, worldTotal, count, totalRows) {
const el = document.getElementById(elId);
if (!el) return;
- el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
- if (!has) { el.textContent = '—'; return; }
- if (count < totalRows) {
- el.innerHTML = `${formatCurrency(total)}*`;
- } else {
- el.textContent = formatCurrency(total);
+ el.className = el.className
+ .replace(/\btext-red-\d+\b/g, '')
+ .replace(/\bpricing-total-tip\b/g, '')
+ .trim();
+ if (!has) {
+ el.textContent = '—';
+ return;
}
+
+ const share = total > 0 ? (worldTotal / total) * 100 : 0;
+ const parts = [
+ share > 0
+ ? `${formatPercent(share)}% от этой суммы посчитано по ценам прайслиста WORLD — это заглушка, а не реальная цена`
+ : 'Цены прайслиста WORLD в этой сумме не использованы'
+ ];
+ if (count < totalRows) {
+ parts.push(`Цены есть не для всех позиций: ${count} из ${totalRows}`);
+ }
+
+ el.className = `${el.className} text-red-600 pricing-total-tip`.trim();
+ el.innerHTML = `${formatCurrency(total)}${parts.join(' ')}`;
+}
+
+// One decimal, comma separator, no trailing ",0".
+function formatPercent(v) {
+ return v.toFixed(1).replace(/\.0$/, '').replace('.', ',');
}
function parseDecimalInput(raw) {