diff --git a/bible-local/03-database.md b/bible-local/03-database.md index dd3bbc4..73277b2 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -26,7 +26,7 @@ Rules: - user-authored tables must not be dropped as a recovery shortcut; - `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table; - `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`; -- `local_pricelist_items.lot_description` is the single source of a LOT's description at runtime (populated by sync from `qt_pricelist_items.lot_description`); do not join the legacy `lot` table for it; +- `local_pricelist_items.lot_description` is the single source of a LOT's description at runtime (populated by sync from `qt_pricelist_items.lot_description`); do not join the legacy `lot` table for it. Every read path resolves it through the currently selected pricelist (active estimate, or the configuration's own `PricelistID` where applicable) — see `LocalDB.GetLocalComponent`/`ListComponents` (component views, quote line descriptions) and `LocalDB.GetLocalDescriptionsForLots` (export pricing rows in `internal/services/export.go`). Do not add a second description source (e.g. `VendorSpecItem.Description` from BOM import is a distinct, separate field — it's only ever used as a display fallback in front of the LOT description, never merged back into it); - configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows; - `local_components` table has been removed; any reference to it is dead code. @@ -259,7 +259,9 @@ PK: lot_name | lot_category | varchar(50) | | | lot_description | varchar(10000) | added by migration 032; backfilled once from `lot.lot_description`. QF syncs this column directly — do not join `lot` per-row to get a description | | 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. UI: 7-9 = no warning, 4-6 = amber "!", 0-3 = red "!" (`formatPriceQualityWarning` in `pricelist_detail.html`) | +| 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). 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/internal/handlers/component.go b/internal/handlers/component.go index 566591d..aaecd1c 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -51,6 +51,7 @@ func (h *ComponentHandler) List(c *gin.Context) { Category: lc.Category, CategoryName: lc.Category, Model: lc.Model, + PriceQuality: lc.PriceQuality, } } @@ -81,6 +82,7 @@ func (h *ComponentHandler) Get(c *gin.Context) { Category: component.Category, CategoryName: component.Category, Model: component.Model, + PriceQuality: component.PriceQuality, }) } diff --git a/internal/localdb/components.go b/internal/localdb/components.go index 2889163..e0e5078 100644 --- a/internal/localdb/components.go +++ b/internal/localdb/components.go @@ -42,9 +42,10 @@ func (l *LocalDB) latestActivePricelistID(source string) (uint, error) { // pricelistItemRow is used for scanning rows from local_pricelist_items. type pricelistItemRow struct { - LotName string `gorm:"column:lot_name"` - Category string `gorm:"column:lot_category"` - Description string `gorm:"column:lot_description"` + LotName string `gorm:"column:lot_name"` + Category string `gorm:"column:lot_category"` + Description string `gorm:"column:lot_description"` + PriceQuality *int `gorm:"column:price_quality"` } func (r pricelistItemRow) toLocalComponent() LocalComponent { @@ -52,6 +53,7 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent { LotName: r.LotName, Category: r.Category, LotDescription: r.Description, + PriceQuality: r.PriceQuality, } } @@ -73,7 +75,7 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone } var rows []pricelistItemRow - if err := db.Select("lot_name, lot_category, lot_description").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)) @@ -101,7 +103,7 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit } var rows []pricelistItemRow - if err := db.Select("lot_name, lot_category, lot_description").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)) @@ -135,7 +137,7 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L } var rows []pricelistItemRow - if err := db.Select("lot_name, lot_category, lot_description").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil { + if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil { return nil, 0, err } components := make([]LocalComponent, len(rows)) @@ -155,7 +157,7 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { var row pricelistItemRow if err := l.db.Table("local_pricelist_items"). - Select("lot_name, lot_category, lot_description"). + Select("lot_name, lot_category, lot_description, price_quality"). Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)). First(&row).Error; err != nil { return nil, err diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index c031fe7..9c52121 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1630,6 +1630,51 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin return result, nil } +// GetLocalDescriptionsForLots returns lot_description for each lot_name from a local +// pricelist resolved by server ID. Missing lots are not included in the map. +func (l *LocalDB) GetLocalDescriptionsForLots(serverPricelistID uint, lotNames []string) (map[string]string, error) { + result := make(map[string]string, len(lotNames)) + if serverPricelistID == 0 || len(lotNames) == 0 { + return result, nil + } + + localPL, err := l.GetLocalPricelistByServerID(serverPricelistID) + if err != nil { + return nil, err + } + + type row struct { + LotName string `gorm:"column:lot_name"` + LotDescription string `gorm:"column:lot_description"` + } + // Build uppercase → original mapping so result keys match what the caller passed. + upperToOrig := make(map[string]string, len(lotNames)) + upper := make([]string, len(lotNames)) + for i, n := range lotNames { + u := strings.ToUpper(n) + upper[i] = u + upperToOrig[u] = n + } + var rows []row + if err := l.db.Model(&LocalPricelistItem{}). + Select("lot_name, lot_description"). + Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper). + Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if r.LotDescription == "" { + continue + } + orig := upperToOrig[strings.ToUpper(r.LotName)] + if orig == "" { + orig = r.LotName + } + result[orig] = r.LotDescription + } + return result, nil +} + // MarkPricelistAsUsed marks a pricelist as used by a configuration func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error { return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID). diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 3cbfcdd..da8e6f9 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -213,6 +213,7 @@ type LocalComponent struct { LotDescription string `json:"lot_description"` Category string `json:"category"` Model string `json:"model"` + PriceQuality *int `json:"price_quality,omitempty"` } func (LocalComponent) TableName() string { diff --git a/internal/services/component.go b/internal/services/component.go index f31c0f0..e3a5e46 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -14,4 +14,5 @@ type ComponentView struct { Category string `json:"category"` CategoryName string `json:"category_name"` Model string `json:"model"` + PriceQuality *int `json:"price_quality,omitempty"` } diff --git a/internal/services/export.go b/internal/services/export.go index 9d1b344..79f8874 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -687,8 +687,35 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string return prices } -func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string { - return map[string]string{} +// resolveLotDescriptions returns each LOT's description from the configuration's +// currently selected estimate pricelist (falling back to the latest active one), +// mirroring the pricelist resolution in resolvePricingTotals. This is the single +// source of LOT description across the project — see bible-local/03-database.md. +func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string { + if s.localDB == nil { + return map[string]string{} + } + + lots := collectPricingLots(cfg, localCfg, true) + if len(lots) == 0 { + return map[string]string{} + } + + estimateID := cfg.PricelistID + if estimateID == nil || *estimateID == 0 { + if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil { + estimateID = &latest.ServerID + } + } + if estimateID == nil || *estimateID == 0 { + return map[string]string{} + } + + descriptions, err := s.localDB.GetLocalDescriptionsForLots(*estimateID, lots) + if err != nil { + return map[string]string{} + } + return descriptions } func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { diff --git a/web/static/price-quality.js b/web/static/price-quality.js new file mode 100644 index 0000000..7c9fa19 --- /dev/null +++ b/web/static/price-quality.js @@ -0,0 +1,55 @@ +// Shared LOT price-quality color scale, used everywhere price_quality is +// displayed (pricelist detail page, configurator search dropdown, configurator +// table, pricing tab). price_quality is a 0-9 score set by the external +// pricing tool (see bible-local/03-database.md, qt_pricelist_items.price_quality) +// — QF only displays it, never computes it. Single source of the color scale +// so every view stays visually consistent. +// +// Gradient: 0 = red, 5 = yellow, 9 = green. +(function () { + const RED = [220, 38, 38]; + const YELLOW = [234, 179, 8]; + const GREEN = [22, 163, 74]; + + function lerp(c1, c2, t) { + return c1.map((v, i) => Math.round(v + (c2[i] - v) * t)); + } + + // Returns "r, g, b" (no rgb() wrapper) so callers can build rgb()/rgba() themselves. + function priceQualityRgb(quality) { + if (typeof quality !== 'number' || Number.isNaN(quality)) return null; + const q = Math.max(0, Math.min(9, quality)); + const [r, g, b] = q <= 5 ? lerp(RED, YELLOW, q / 5) : lerp(YELLOW, GREEN, (q - 5) / 4); + return `${r}, ${g}, ${b}`; + } + + function priceQualityColor(quality) { + const rgb = priceQualityRgb(quality); + return rgb ? `rgb(${rgb})` : null; + } + + // Small colored dot for compact contexts (search dropdown, configurator table cell). + function qualityDotHtml(quality) { + const color = priceQualityColor(quality); + if (!color) return ''; + return ``; + } + + // Colored number badge for a dedicated "quality" column/cell. + function qualityBadgeHtml(quality) { + if (typeof quality !== 'number') return '-'; + const color = priceQualityColor(quality); + return `${quality}`; + } + + // Light row-tint background for table rows, e.g. the pricing tab. + function qualityRowStyle(quality) { + const rgb = priceQualityRgb(quality); + return rgb ? `background-color: rgba(${rgb}, 0.10)` : ''; + } + + window.priceQualityColor = priceQualityColor; + window.qualityDotHtml = qualityDotHtml; + window.qualityBadgeHtml = qualityBadgeHtml; + window.qualityRowStyle = qualityRowStyle; +})(); diff --git a/web/templates/base.html b/web/templates/base.html index 001eb25..54bdcf4 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -8,6 +8,7 @@ +