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 @@
+