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 = 'Нет данных для отображения'; + tbodyBuy.innerHTML = 'Нет данных для отображения'; tfootBuy.classList.add('hidden'); } else { let totEst = 0, totWh = 0, totComp = 0, totVendor = 0; let hasEst = false, hasWh = false, hasComp = false, hasVendor = false; let cntWh = 0, cntComp = 0; + let worldEst = 0, worldWh = 0, worldComp = 0; rowData.forEach(r => { const tr = document.createElement('tr'); tr.classList.add('pricing-row-buy'); @@ -4681,43 +4739,44 @@ async function renderPricingTab() { tr.dataset.vendorPn = r.vendorPN || ''; tr.dataset.desc = r.desc; tr.dataset.lot = r.lotText; - if (r.est > 0) { totEst += r.est; hasEst = true; } - if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; } - if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; } + if (r.est > 0) { totEst += r.est; hasEst = true; if (r.estWorld) worldEst += r.est; } + if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; if (r.whWorld) worldWh += r.warehouse; } + if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; if (r.compWorld) worldComp += r.competitor; } if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; } const borderTop = r.groupStart ? 'border-t border-gray-200' : ''; const pnDescHtml = r.groupStart ? (() => { const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : ''; - return `${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} - ${escapeHtml(r.desc)}`; + return `${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} + ${escapeHtml(r.desc)}`; })() : ''; tr.innerHTML = ` ${pnDescHtml} - ${r.lotCell} - ${r.qty} - ${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} - ${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} - ${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} - ${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'} + ${r.lotCell} + ${r.qty} + ${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} + ${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} + ${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} + ${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'} `; tbodyBuy.appendChild(tr); }); - document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—'; - document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—'; - _setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length); - _setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, rowData.length); + document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—'; + _setPricingTotal('pricing-total-buy-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length); + _setPricingTotal('pricing-total-buy-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length); + _setPricingTotal('pricing-total-buy-competitor', hasComp, totComp, worldComp, cntComp, rowData.length); tfootBuy.classList.remove('hidden'); } // ─── Populate Sale table ───────────────────────────────────────────────── tbodySale.innerHTML = ''; if (!rowData.length) { - tbodySale.innerHTML = 'Нет данных для отображения'; + tbodySale.innerHTML = 'Нет данных для отображения'; tfootSale.classList.add('hidden'); } else { let totEst = 0, totWh = 0, totComp = 0; let hasEst = false, hasWh = false, hasComp = false; let cntWh = 0, cntComp = 0; + let worldEst = 0, worldWh = 0, worldComp = 0; rowData.forEach(r => { const tr = document.createElement('tr'); tr.classList.add('pricing-row-sale'); @@ -4738,30 +4797,30 @@ async function renderPricingTab() { tr.dataset.vendorPn = r.vendorPN || ''; tr.dataset.desc = r.desc; tr.dataset.lot = r.lotText; - if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; } - if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; } - if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; } + if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; } + if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; } + if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; } const borderTop = r.groupStart ? 'border-t border-gray-200' : ''; const pnDescHtml = r.groupStart ? (() => { const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : ''; - return `${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} - ${escapeHtml(r.desc)}`; + return `${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'} + ${escapeHtml(r.desc)}`; })() : ''; tr.innerHTML = ` ${pnDescHtml} - ${r.lotCell} - ${r.qty} - ${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'} - ${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'} - ${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'} - — + ${r.lotCell} + ${r.qty} + ${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'} + ${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'} + ${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'} + — `; 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) {