feat: PN code в импорте BOM, объединённый список LOT (world ∪ estimate)

Колонка «PN code» в таблице импорта BOM: HPE-спецификации хранят партномер
в двух столбцах, канонический вид — P52534-B21#B19. Склейка попадает
в vendor_partnumber и сравнивается с книгой партномеров как есть; при
загрузке разбирается обратно на две колонки. Схему БД менять не пришлось.

Список допустимых LOT собирается из world ∪ estimate (componentUniverse),
а не только из estimate. Прежняя область видимости молча теряла
lot_mappings: резолвер сопоставлял PN по книге, которая про прайслисты
не знает, а фронт отбрасывал LOT, которого нет в estimate — при этом
в корзину он всё равно попадал. World-only позиции отдают price_quality 0.

Кнопка «Пересопоставить» — перерешать BOM по актуальной книге, так как
при открытии конфигурации сопоставления остаются замороженными.

Итоги на вкладке «Ценообразование»: звёздочки убраны (переносились
на новую строку), все три суммы красные, при наведении — попап с долей
цен из прайслиста WORLD. Колонка «PN вендора» больше не переносится.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-24 17:01:31 +03:00
co-authored by Claude Opus 4.8
parent 0fa7b0b1b6
commit 905f9a4952
7 changed files with 547 additions and 119 deletions
+1 -1
View File
@@ -261,7 +261,7 @@ PK: lot_name
| price | decimal(12,2) NOT NULL | | | 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 | 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. 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.
+27
View File
@@ -25,6 +25,33 @@ Rules:
- QuoteForge does not use legacy BOM tables; - QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`. - 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
Partnumber books are pull-only snapshots from PriceForge. Partnumber books are pull-only snapshots from PriceForge.
@@ -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.
+176
View File
@@ -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)
}
})
}
+84 -36
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"gorm.io/gorm"
) )
// ComponentFilter for searching with filters // ComponentFilter for searching with filters
@@ -40,6 +42,60 @@ func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
return id, nil 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. // pricelistItemRow is used for scanning rows from local_pricelist_items.
type pricelistItemRow struct { type pricelistItemRow struct {
LotName string `gorm:"column:lot_name"` LotName string `gorm:"column:lot_name"`
@@ -57,25 +113,23 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent {
} }
} }
// SearchLocalComponents searches components in the latest active estimate // SearchLocalComponents searches the component universe by lot_name.
// pricelist by lot_name.
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) { func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return nil, err return nil, err
} }
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if query != "" { if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%") db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
} }
var rows []pricelistItemRow 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 return nil, err
} }
components := make([]LocalComponent, len(rows)) components := make([]LocalComponent, len(rows))
@@ -85,25 +139,24 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone
return components, nil return components, nil
} }
// SearchLocalComponentsByCategory searches components in the latest active // SearchLocalComponentsByCategory searches the component universe filtered by category.
// estimate pricelist filtered by category.
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) { func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return nil, err return nil, err
} }
db := l.db.Table("local_pricelist_items"). db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(category))
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
if query != "" { if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%") db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
} }
var rows []pricelistItemRow 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 return nil, err
} }
components := make([]LocalComponent, len(rows)) components := make([]LocalComponent, len(rows))
@@ -113,17 +166,14 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit
return components, nil return components, nil
} }
// ListComponents returns components from the latest active estimate pricelist // ListComponents returns components from the component universe with optional
// with optional category/search filtering and pagination. // category/search filtering and pagination.
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) { func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if filter.Category != "" { if filter.Category != "" {
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(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 return components, total, nil
} }
// GetLocalComponent returns a single component by lot_name from the latest // GetLocalComponent returns a single component by lot_name from the component universe.
// active estimate pricelist.
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var row pricelistItemRow var row pricelistItemRow
if err := l.db.Table("local_pricelist_items"). if err := db.
Select("lot_name, lot_category, lot_description, price_quality"). 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 { First(&row).Error; err != nil {
return nil, err return nil, err
} }
@@ -167,13 +216,13 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
} }
// GetLocalComponentCategoriesByLotNames returns category for each lot_name // 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) { func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames)) result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 { if len(lotNames) == 0 {
return result, nil return result, nil
} }
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return result, nil return result, nil
} }
@@ -187,9 +236,9 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
upperToOrig[u] = n upperToOrig[u] = n
} }
var rows []pricelistItemRow var rows []pricelistItemRow
if err := l.db.Table("local_pricelist_items"). if err := db.
Select("lot_name, lot_category"). 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 { Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -203,17 +252,16 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
return result, nil return result, nil
} }
// GetLocalComponentCategories returns distinct categories from the latest // GetLocalComponentCategories returns distinct categories from the component universe.
// active estimate pricelist.
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var categories []string var categories []string
if err := l.db.Table("local_pricelist_items"). if err := db.
Where("pricelist_id = ? AND lot_category != ''", pricelistID). Where("lot_category != ''").
Distinct("lot_category"). Distinct("lot_category").
Order("lot_category"). Order("lot_category").
Pluck("lot_category", &categories).Error; err != nil { Pluck("lot_category", &categories).Error; err != nil {
@@ -222,14 +270,14 @@ func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
return categories, nil return categories, nil
} }
// CountComponents returns the number of distinct lot names in the latest // CountComponents returns the number of lot names in the component universe
// active estimate pricelist (used to check if data is available). // (used to check if data is available).
func (l *LocalDB) CountComponents() int64 { func (l *LocalDB) CountComponents() int64 {
pricelistID, err := l.latestActivePricelistID("estimate") db, err := l.componentUniverse()
if err != nil { if err != nil {
return 0 return 0
} }
var count int64 var count int64
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count) db.Count(&count)
return count return count
} }
+37
View File
@@ -7,3 +7,40 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; 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;
}
+163 -80
View File
@@ -194,6 +194,9 @@
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300"> <button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
Очистить Очистить
</button> </button>
<button onclick="reresolveBOM()" title="Заново сопоставить строки BOM по актуальной книге партномеров" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
Пересопоставить
</button>
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"> <button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
Сохранить BOM Сохранить BOM
</button> </button>
@@ -219,26 +222,26 @@
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700"> <thead class="bg-gray-50 text-gray-700">
<tr> <tr>
<th class="px-3 py-2 text-left border-b">PN вендора</th> <th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th> <th class="px-2 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</th> <th class="px-2 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th> <th class="px-2 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th> <th class="px-2 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th> <th class="px-2 py-2 text-right border-b">Склад</th>
<th class="px-3 py-2 text-right border-b">Конкуренты</th> <th class="px-2 py-2 text-right border-b">Конкуренты</th>
<th class="px-3 py-2 text-right border-b">Ручная цена</th> <th class="px-2 py-2 text-right border-b">Ручная цена</th>
</tr> </tr>
</thead> </thead>
<tbody id="pricing-body-buy"> <tbody id="pricing-body-buy">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> <tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody> </tbody>
<tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold"> <tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
<tr> <tr>
<td colspan="4" class="px-3 py-2 text-right">Итого:</td> <td colspan="4" class="px-2 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right" id="pricing-total-buy-estimate"></td> <td class="px-2 py-2 text-right" id="pricing-total-buy-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-buy-warehouse"></td> <td class="px-2 py-2 text-right" id="pricing-total-buy-warehouse"></td>
<td class="px-3 py-2 text-right" id="pricing-total-buy-competitor"></td> <td class="px-2 py-2 text-right" id="pricing-total-buy-competitor"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor"></td> <td class="px-2 py-2 text-right font-bold" id="pricing-total-buy-vendor"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -268,26 +271,26 @@
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700"> <thead class="bg-gray-50 text-gray-700">
<tr> <tr>
<th class="px-3 py-2 text-left border-b">PN вендора</th> <th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th> <th class="px-2 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</th> <th class="px-2 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th> <th class="px-2 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th> <th class="px-2 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th> <th class="px-2 py-2 text-right border-b">Склад</th>
<th class="px-3 py-2 text-right border-b">Конкуренты</th> <th class="px-2 py-2 text-right border-b">Конкуренты</th>
<th class="px-3 py-2 text-right border-b">Ручная цена</th> <th class="px-2 py-2 text-right border-b">Ручная цена</th>
</tr> </tr>
</thead> </thead>
<tbody id="pricing-body-sale"> <tbody id="pricing-body-sale">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> <tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody> </tbody>
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold"> <tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
<tr> <tr>
<td colspan="4" class="px-3 py-2 text-right">Итого:</td> <td colspan="4" class="px-2 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-estimate"></td> <td class="px-2 py-2 text-right" id="pricing-total-sale-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-warehouse"></td> <td class="px-2 py-2 text-right" id="pricing-total-sale-warehouse"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-competitor"></td> <td class="px-2 py-2 text-right" id="pricing-total-sale-competitor"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-sale-vendor"></td> <td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-vendor"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -3480,6 +3483,7 @@ let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRow
const BOM_COL_TYPES = [ const BOM_COL_TYPES = [
{ value: 'ignore', label: 'Не использовать' }, { value: 'ignore', label: 'Не использовать' },
{ value: 'pn', label: 'P/N' }, { value: 'pn', label: 'P/N' },
{ value: 'pn_code', label: 'PN code' },
{ value: 'qty', label: 'Кол-во' }, { value: 'qty', label: 'Кол-во' },
{ value: 'price', label: 'Цена' }, { value: 'price', label: 'Цена' },
{ value: 'description', label: 'Описание' } { value: 'description', label: 'Описание' }
@@ -3490,6 +3494,7 @@ function _bomRawHeaderWidthClass(colType) {
case 'qty': return 'w-24 min-w-24'; case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32'; case 'price': return 'w-32 min-w-32';
case 'pn': return 'min-w-40'; case 'pn': return 'min-w-40';
case 'pn_code': return 'w-28 min-w-28';
case 'description': return 'min-w-48'; case 'description': return 'min-w-48';
default: return 'min-w-28'; default: return 'min-w-28';
} }
@@ -3499,10 +3504,32 @@ function _bomRawCellWidthClass(colType) {
switch (colType) { switch (colType) {
case 'qty': return 'w-24 min-w-24'; case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32'; case 'price': return 'w-32 min-w-32';
case 'pn_code': return 'w-28 min-w-28';
default: return ''; 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) { function parsePastePrice(s) {
if (!s) return null; if (!s) return null;
let v = String(s).replace(/[$\s]/g, ''); let v = String(s).replace(/[$\s]/g, '');
@@ -3649,7 +3676,7 @@ async function handleBOMPaste(event) {
function _getBomColumnTypeIndexes() { function _getBomColumnTypeIndexes() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null; 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); }); bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
return idx; return idx;
} }
@@ -3659,6 +3686,7 @@ function _validateBomColumnTypes() {
const idx = _getBomColumnTypeIndexes(); const idx = _getBomColumnTypeIndexes();
if (!idx) return { ok: false, message: 'Нет данных BOM' }; if (!idx) return { ok: false, message: 'Нет данных BOM' };
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' }; 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.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' }; if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
if (idx.description.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(); if (manual && _bomLotValid(manual)) return manual.toUpperCase();
return ''; 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) { function _getRowCanonicalLotMappings(row) {
const out = []; const out = [];
const baseLot = _getRowBaseLot(row); const baseLot = _getRowBaseLot(row);
if (baseLot && _bomLotValid(baseLot)) { if (baseLot && _bomLotPersistable(row, baseLot)) {
out.push({ out.push({
lot_name: baseLot, lot_name: baseLot,
quantity_per_pn: _getRowLotQtyPerPN(row) quantity_per_pn: _getRowLotQtyPerPN(row)
@@ -3980,7 +4018,6 @@ function rebuildBOMRowsFromRaw() {
_setBomUIError(''); _setBomUIError('');
const idx = validation.idx; const idx = validation.idx;
const pnCol = idx.pn[0];
const qtyCol = idx.qty[0]; const qtyCol = idx.qty[0];
const priceCol = idx.price.length ? idx.price[0] : -1; const priceCol = idx.price.length ? idx.price[0] : -1;
const descCol = idx.description.length ? idx.description[0] : -1; const descCol = idx.description.length ? idx.description[0] : -1;
@@ -3988,7 +4025,7 @@ function rebuildBOMRowsFromRaw() {
const nextRows = []; const nextRows = [];
bomImportRaw.rows.forEach((cols, rowIdx) => { bomImportRaw.rows.forEach((cols, rowIdx) => {
if (bomImportRaw.ignoredRows?.[rowIdx]) return; if (bomImportRaw.ignoredRows?.[rowIdx]) return;
const pn = ((cols[pnCol] || '') + '').trim(); const pn = _bomComposePN(cols, idx);
if (!pn) return; if (!pn) return;
const qtyRaw = ((cols[qtyCol] || '') + '').trim(); const qtyRaw = ((cols[qtyCol] || '') + '').trim();
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) { if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
@@ -4067,13 +4104,12 @@ async function resolveBOM() {
if (bomImportRaw && bomImportRaw.mode === 'raw') { if (bomImportRaw && bomImportRaw.mode === 'raw') {
const validation = _validateBomColumnTypes(); const validation = _validateBomColumnTypes();
if (validation.ok) { if (validation.ok) {
const pnCol = validation.idx.pn[0];
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1; const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
ignoredSeen = (bomImportRaw.rows || []) ignoredSeen = (bomImportRaw.rows || [])
.map((cols, rowIdx) => ({ cols, rowIdx })) .map((cols, rowIdx) => ({ cols, rowIdx }))
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx]) .filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
.map(({ cols }) => ({ .map(({ cols }) => ({
partnumber: ((cols[pnCol] || '') + '').trim(), partnumber: _bomComposePN(cols, validation.idx),
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '', description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
ignored: true ignored: true
})) }))
@@ -4245,6 +4281,21 @@ function renderBOMTable() {
if (currentTopTab === 'pricing') renderPricingTab(); 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; let _resolveBOMTimer = null;
function debouncedResolveBOM() { function debouncedResolveBOM() {
clearTimeout(_resolveBOMTimer); clearTimeout(_resolveBOMTimer);
@@ -4416,16 +4467,22 @@ async function loadVendorSpec(configUUID) {
}); });
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste). // 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 = { bomImportRaw = {
mode: 'raw', mode: 'raw',
rows: bomRows.map(r => ([ rows: bomRows.map(r => {
String(r.quantity ?? 1), const [pn, code] = _bomSplitPN(r.vendor_pn || '');
r.vendor_pn || '', const cells = [String(r.quantity ?? 1), pn];
r.description || '', if (hasPNCode) cells.push(code);
r.unit_price != null ? String(r.unit_price) : '' cells.push(r.description || '', r.unit_price != null ? String(r.unit_price) : '');
])), return cells;
columnTypes: ['qty', 'pn', 'description', 'price'], }),
columnTypes: hasPNCode
? ['qty', 'pn', 'pn_code', 'description', 'price']
: ['qty', 'pn', 'description', 'price'],
ignoredRows: {}, ignoredRows: {},
rowErrors: {}, rowErrors: {},
uiError: '' uiError: ''
@@ -4659,12 +4716,13 @@ async function renderPricingTab() {
// ─── Populate Buy table ────────────────────────────────────────────────── // ─── Populate Buy table ──────────────────────────────────────────────────
tbodyBuy.innerHTML = ''; tbodyBuy.innerHTML = '';
if (!rowData.length) { if (!rowData.length) {
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>'; tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootBuy.classList.add('hidden'); tfootBuy.classList.add('hidden');
} else { } else {
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0; let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false; let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
let cntWh = 0, cntComp = 0; let cntWh = 0, cntComp = 0;
let worldEst = 0, worldWh = 0, worldComp = 0;
rowData.forEach(r => { rowData.forEach(r => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.classList.add('pricing-row-buy'); tr.classList.add('pricing-row-buy');
@@ -4681,43 +4739,44 @@ async function renderPricingTab() {
tr.dataset.vendorPn = r.vendorPN || ''; tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc; tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText; tr.dataset.lot = r.lotText;
if (r.est > 0) { totEst += r.est; hasEst = true; } 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.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.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; if (r.compWorld) worldComp += r.competitor; }
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; } if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : ''; const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => { const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : ''; const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td> return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`; <td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : ''; })() : '';
tr.innerHTML = ` tr.innerHTML = `
${pnDescHtml} ${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
`; `;
tbodyBuy.appendChild(tr); tbodyBuy.appendChild(tr);
}); });
document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—'; document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
_setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length); _setPricingTotal('pricing-total-buy-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
_setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, 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'); tfootBuy.classList.remove('hidden');
} }
// ─── Populate Sale table ───────────────────────────────────────────────── // ─── Populate Sale table ─────────────────────────────────────────────────
tbodySale.innerHTML = ''; tbodySale.innerHTML = '';
if (!rowData.length) { if (!rowData.length) {
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>'; tbodySale.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootSale.classList.add('hidden'); tfootSale.classList.add('hidden');
} else { } else {
let totEst = 0, totWh = 0, totComp = 0; let totEst = 0, totWh = 0, totComp = 0;
let hasEst = false, hasWh = false, hasComp = false; let hasEst = false, hasWh = false, hasComp = false;
let cntWh = 0, cntComp = 0; let cntWh = 0, cntComp = 0;
let worldEst = 0, worldWh = 0, worldComp = 0;
rowData.forEach(r => { rowData.forEach(r => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.classList.add('pricing-row-sale'); tr.classList.add('pricing-row-sale');
@@ -4738,30 +4797,30 @@ async function renderPricingTab() {
tr.dataset.vendorPn = r.vendorPN || ''; tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc; tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText; tr.dataset.lot = r.lotText;
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; } if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; } if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; }
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; } if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : ''; const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => { const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : ''; const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td> return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`; <td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : ''; })() : '';
tr.innerHTML = ` tr.innerHTML = `
${pnDescHtml} ${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td> <td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
`; `;
tbodySale.appendChild(tr); tbodySale.appendChild(tr);
}); });
document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-sale-vendor').textContent = '—'; document.getElementById('pricing-total-sale-vendor').textContent = '—';
_setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length); _setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
_setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, 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'); tfootSale.classList.remove('hidden');
} }
@@ -4772,17 +4831,41 @@ async function renderPricingTab() {
// ─── Pricing helpers ───────────────────────────────────────────────────────── // ─── Pricing helpers ─────────────────────────────────────────────────────────
// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk. // Sets a footer total cell. The total is always red — it is a sum the user must not read
function _setPartialTotal(elId, has, total, count, totalRows) { // 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); const el = document.getElementById(elId);
if (!el) return; if (!el) return;
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim(); el.className = el.className
if (!has) { el.textContent = ''; return; } .replace(/\btext-red-\d+\b/g, '')
if (count < totalRows) { .replace(/\bpricing-total-tip\b/g, '')
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`; .trim();
} else { if (!has) {
el.textContent = formatCurrency(total); el.textContent = '—';
return;
} }
const share = total > 0 ? (worldTotal / total) * 100 : 0;
const parts = [
share > 0
? `<b>${formatPercent(share)}%</b> от этой суммы посчитано по ценам прайслиста WORLD — это заглушка, а не реальная цена`
: 'Цены прайслиста WORLD в этой сумме не использованы'
];
if (count < totalRows) {
parts.push(`Цены есть не для всех позиций: ${count} из ${totalRows}`);
}
el.className = `${el.className} text-red-600 pricing-total-tip`.trim();
el.innerHTML = `${formatCurrency(total)}<span class="pricing-total-tip__body">${parts.join('<br>')}</span>`;
}
// One decimal, comma separator, no trailing ",0".
function formatPercent(v) {
return v.toFixed(1).replace(/\.0$/, '').replace('.', ',');
} }
function parseDecimalInput(raw) { function parseDecimalInput(raw) {