diff --git a/bible-local/03-database.md b/bible-local/03-database.md index bc20d33..dd3bbc4 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -259,12 +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_method | varchar(20) | synced to local and shown in the pricelist detail "Настройки" column | -| price_period_days | bigint DEFAULT 90 | synced to local, same as above | -| price_coefficient | decimal(5,2) DEFAULT 0 | synced to local, same as above | -| manual_price | decimal(12,2) | synced to local, same as above | -| meta_prices | varchar(1000) | synced to local, same as above | -| lead_time_weeks | int | exists in the real table; not read by QF — no corresponding field in `models.PricelistItem` | +| 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`) | + +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. Fields QF never populates and does not model: `AvailableQty`/`Partnumbers` (removed from `models.PricelistItem` and `LocalPricelistItem` — no writer ever set them; always empty). diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index ad4548e..db244c8 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -180,16 +180,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { resultItems := make([]gin.H, 0, len(items)) for _, item := range items { resultItems = append(resultItems, gin.H{ - "id": item.ID, - "lot_name": item.LotName, - "lot_description": item.LotDescription, - "price": item.Price, - "category": item.LotCategory, - "price_method": item.PriceMethod, - "price_period_days": item.PricePeriodDays, - "price_coefficient": item.PriceCoefficient, - "manual_price": item.ManualPrice, - "meta_prices": item.MetaPrices, + "id": item.ID, + "lot_name": item.LotName, + "lot_description": item.LotDescription, + "price": item.Price, + "category": item.LotCategory, + "price_quality": item.PriceQuality, }) } diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 238b77e..71e80be 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -332,32 +332,24 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist { // PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem { return &LocalPricelistItem{ - PricelistID: localPricelistID, - LotName: models.NormalizeLotName(item.LotName), - LotCategory: item.LotCategory, - LotDescription: item.LotDescription, - Price: item.Price, - PriceMethod: item.PriceMethod, - PricePeriodDays: item.PricePeriodDays, - PriceCoefficient: item.PriceCoefficient, - ManualPrice: item.ManualPrice, - MetaPrices: item.MetaPrices, + PricelistID: localPricelistID, + LotName: models.NormalizeLotName(item.LotName), + LotCategory: item.LotCategory, + LotDescription: item.LotDescription, + Price: item.Price, + PriceQuality: item.PriceQuality, } } // LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem { return &models.PricelistItem{ - ID: local.ID, - PricelistID: serverPricelistID, - LotName: local.LotName, - LotCategory: local.LotCategory, - LotDescription: local.LotDescription, - Price: local.Price, - PriceMethod: local.PriceMethod, - PricePeriodDays: local.PricePeriodDays, - PriceCoefficient: local.PriceCoefficient, - ManualPrice: local.ManualPrice, - MetaPrices: local.MetaPrices, + ID: local.ID, + PricelistID: serverPricelistID, + LotName: local.LotName, + LotCategory: local.LotCategory, + LotDescription: local.LotDescription, + Price: local.Price, + PriceQuality: local.PriceQuality, } } diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index 3773079..31ab948 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -130,13 +130,18 @@ var localMigrations = []localMigration{ run: addLocalProjectRentalEnabled, }, { - id: "2026_07_22_pricelist_items_price_settings", - name: "Add lot_description and price settings columns to local_pricelist_items, drop unused available_qty/partnumbers", - run: addPricelistItemDescriptionAndPriceSettings, + id: "2026_07_22_pricelist_items_add_description_drop_unused", + name: "Add lot_description to local_pricelist_items, drop unused available_qty/partnumbers", + run: addPricelistItemDescriptionDropUnused, + }, + { + id: "2026_07_22_pricelist_items_price_quality", + name: "Add price_quality to local_pricelist_items", + run: addLocalPricelistItemPriceQuality, }, } -func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { +func addLocalPricelistItemPriceQuality(tx *gorm.DB) error { type columnInfo struct { Name string `gorm:"column:name"` } @@ -144,7 +149,33 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { var columns []columnInfo if err := tx.Raw(` SELECT name FROM pragma_table_info('local_pricelist_items') - WHERE name IN ('lot_description', 'price_method', 'price_period_days', 'price_coefficient', 'manual_price', 'meta_prices', 'available_qty', 'partnumbers') + WHERE name = 'price_quality' + `).Scan(&columns).Error; err != nil { + return fmt.Errorf("check local_pricelist_items(price_quality) existence: %w", err) + } + + if len(columns) > 0 { + slog.Info("price_quality already present on local_pricelist_items") + return nil + } + + if err := tx.Exec(`ALTER TABLE local_pricelist_items ADD COLUMN price_quality INTEGER`).Error; err != nil { + return fmt.Errorf("add local_pricelist_items.price_quality: %w", err) + } + + slog.Info("added price_quality to local_pricelist_items") + return nil +} + +func addPricelistItemDescriptionDropUnused(tx *gorm.DB) error { + type columnInfo struct { + Name string `gorm:"column:name"` + } + + var columns []columnInfo + if err := tx.Raw(` + SELECT name FROM pragma_table_info('local_pricelist_items') + WHERE name IN ('lot_description', 'available_qty', 'partnumbers') `).Scan(&columns).Error; err != nil { return fmt.Errorf("check local_pricelist_items columns: %w", err) } @@ -154,16 +185,13 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { have[c.Name] = true } - if have["lot_description"] && have["price_method"] && have["price_period_days"] && - have["price_coefficient"] && have["manual_price"] && have["meta_prices"] && - !have["available_qty"] && !have["partnumbers"] { - slog.Info("local_pricelist_items already migrated to description/price-settings schema") + if have["lot_description"] && !have["available_qty"] && !have["partnumbers"] { + slog.Info("local_pricelist_items already migrated to description-only schema") return nil } // SQLite: recreate table without the never-populated available_qty/partnumbers - // columns, adding lot_description and the price settings columns synced from - // qt_pricelist_items (needed to render the pricelist detail UI correctly). + // columns, adding lot_description (synced from qt_pricelist_items.lot_description). if err := tx.Exec(` CREATE TABLE local_pricelist_items_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -171,12 +199,7 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { lot_name TEXT NOT NULL, lot_category TEXT, lot_description TEXT, - price REAL NOT NULL, - price_method TEXT, - price_period_days INTEGER DEFAULT 90, - price_coefficient REAL DEFAULT 0, - manual_price REAL, - meta_prices TEXT + price REAL NOT NULL ) `).Error; err != nil { return fmt.Errorf("create new local_pricelist_items table: %w", err) @@ -198,13 +221,6 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { return fmt.Errorf("rename new local_pricelist_items table: %w", err) } - if err := tx.Exec(` - CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot - ON local_pricelist_items(pricelist_id, lot_name) - `).Error; err != nil { - return fmt.Errorf("recreate idx_local_pricelist_items_pricelist_lot: %w", err) - } - if err := tx.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique ON local_pricelist_items(pricelist_id, lot_name) @@ -219,7 +235,7 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error { return fmt.Errorf("recreate idx_local_pricelist_items_lot_category: %w", err) } - slog.Info("added lot_description/price settings columns to local_pricelist_items and dropped available_qty/partnumbers") + slog.Info("added lot_description to local_pricelist_items and dropped available_qty/partnumbers") return nil } @@ -1227,4 +1243,3 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error { slog.Info("deduplicated local_pricelist_items and added unique index") return nil } - diff --git a/internal/localdb/models.go b/internal/localdb/models.go index bf82519..3cbfcdd 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -197,13 +197,9 @@ type LocalPricelistItem struct { LotDescription string `gorm:"column:lot_description" json:"lot_description,omitempty"` Price float64 `gorm:"not null" json:"price"` - // Price calculation settings, mirrored from qt_pricelist_items for display - // in the pricelist detail UI (formatPriceSettings in pricelist_detail.html). - PriceMethod string `gorm:"column:price_method" json:"price_method,omitempty"` - PricePeriodDays int `gorm:"column:price_period_days;default:90" json:"price_period_days"` - PriceCoefficient float64 `gorm:"column:price_coefficient;default:0" json:"price_coefficient"` - ManualPrice *float64 `gorm:"column:manual_price" json:"manual_price,omitempty"` - MetaPrices string `gorm:"column:meta_prices" json:"meta_prices,omitempty"` + // PriceQuality mirrors qt_pricelist_items.price_quality: a 0-9 score set by + // the external pricing tool. 7-9 good, 4-6 warning, 0-3 poor. + PriceQuality *int `gorm:"column:price_quality" json:"price_quality,omitempty"` } func (LocalPricelistItem) TableName() string { diff --git a/internal/models/pricelist.go b/internal/models/pricelist.go index cd4c63d..a5b759e 100644 --- a/internal/models/pricelist.go +++ b/internal/models/pricelist.go @@ -61,14 +61,11 @@ type PricelistItem struct { LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"` LotDescription string `gorm:"column:lot_description;size:10000" json:"lot_description,omitempty"` Price float64 `gorm:"type:decimal(12,2);not null" json:"price"` - PriceMethod string `gorm:"size:20" json:"price_method,omitempty"` - // Price calculation settings (snapshot from qt_lot_metadata), shown in the - // pricelist detail UI as a compact "settings" summary next to each item. - PricePeriodDays int `gorm:"default:90" json:"price_period_days"` - PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"` - ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"` - MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"` + // PriceQuality is a 0-9 score set by the external pricing tool that builds + // the pricelist (based on quote recency/count per its own per-lot settings). + // 7-9 = good, 4-6 = warning, 0-3 = poor. QF only displays it, never computes it. + PriceQuality *int `gorm:"column:price_quality" json:"price_quality,omitempty"` } func (PricelistItem) TableName() string { diff --git a/migrations/033_add_price_quality_to_pricelist_items.sql b/migrations/033_add_price_quality_to_pricelist_items.sql new file mode 100644 index 0000000..1abf690 --- /dev/null +++ b/migrations/033_add_price_quality_to_pricelist_items.sql @@ -0,0 +1,12 @@ +-- Tables affected: qt_pricelist_items +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_pricelist_items DROP COLUMN price_quality; +-- recovery.completed: no action needed +-- verify: price_quality column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_items' AND column_name='price_quality' HAVING COUNT(*)=0 + +-- price_quality is a 0-9 score set by the external pricelist-building tool, +-- based on quote recency/count per its own per-lot pricing-period settings. +-- QuoteForge only reads and displays it (7-9 good, 4-6 warning, 0-3 poor); +-- it never computes or writes this value. +ALTER TABLE qt_pricelist_items + ADD COLUMN IF NOT EXISTS price_quality TINYINT UNSIGNED NULL; diff --git a/web/templates/pricelist_detail.html b/web/templates/pricelist_detail.html index 82eb6e8..5972da3 100644 --- a/web/templates/pricelist_detail.html +++ b/web/templates/pricelist_detail.html @@ -59,7 +59,6 @@ Категория Описание Цена, $ - Настройки @@ -90,7 +89,6 @@ const pl = await resp.json(); currentSource = pl.source || ''; - toggleWarehouseColumns(); document.getElementById('page-title').textContent = `Прайслист ${pl.version}`; document.getElementById('pl-version').textContent = pl.version; @@ -133,7 +131,6 @@ const resp = await fetch(url); const data = await resp.json(); currentSource = data.source || currentSource; - toggleWarehouseColumns(); renderItems(data.items || []); renderItemsPagination(data.total_count, data.page, data.per_page); @@ -158,12 +155,14 @@ } function itemsColspan() { - return isStockSource() ? 4 : 5; + return 4; } - function toggleWarehouseColumns() { - const stock = isStockSource(); - document.getElementById('th-settings').classList.toggle('hidden', stock); + function formatPriceQualityWarning(quality) { + if (typeof quality !== 'number') return ''; + if (quality >= 7) return ''; + const color = quality >= 4 ? 'text-amber-500' : 'text-red-600'; + return ` !`; } function escapeHtml(text) { @@ -176,48 +175,6 @@ .replace(/'/g, '''); } - function formatPriceSettings(item) { - // Format price settings to match admin pricing interface style - let settings = []; - const hasManualPrice = item.manual_price && item.manual_price > 0; - const hasMeta = item.meta_prices && item.meta_prices.trim() !== ''; - const method = (item.price_method || '').toLowerCase(); - - // Method indicator - if (hasManualPrice) { - settings.push('РУЧН'); - } else if (method === 'average') { - settings.push('Сред'); - } else if (method === 'weighted_median') { - settings.push('Взвеш. мед'); - } else { - settings.push('Мед'); - } - - // Period (only if not manual price) - if (!hasManualPrice) { - const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.price_period_days : 90; - if (period === 7) settings.push('1н'); - else if (period === 30) settings.push('1м'); - else if (period === 90) settings.push('3м'); - else if (period === 365) settings.push('1г'); - else if (period === 0) settings.push('все'); - else settings.push(period + 'д'); - } - - // Coefficient - if (item.price_coefficient && item.price_coefficient !== 0) { - settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%'); - } - - // Meta article indicator - if (hasMeta) { - settings.push('МЕТА'); - } - - return settings.join(' | ') || '-'; - } - function renderItems(items) { if (items.length === 0) { document.getElementById('items-body').innerHTML = ` @@ -241,11 +198,12 @@ - // Price cell — add spread badge for competitor + // Price cell — add spread badge for competitor, plus a price-quality warning let priceHtml = price; if (!isWarehouseSource() && item.price_spread_pct > 0) { priceHtml += ` ±${item.price_spread_pct.toFixed(0)}%`; } + priceHtml += formatPriceQualityWarning(item.price_quality); return ` @@ -257,7 +215,6 @@ ${escapeHtml(truncatedDesc)} ${priceHtml} - ${!stock ? `${formatPriceSettings(item)}` : ''} `; }).join('');