feat: price_quality индикатор для прайслиста; убраны неиспользуемые price_method/period/coefficient/manual_price/meta_prices

Прайслисты: добавлено поле price_quality (0-9, выставляется внешней
утилитой ценообразования на основе количества/свежести котировок за
период — QF только отображает, не считает сам). В карточке прайслиста
рядом с ценой показывается предупреждение (жёлтый/красный «!») при
quality < 7.

price_method/price_period_days/price_coefficient/manual_price/meta_prices
убраны из контракта QF (models.PricelistItem, local_pricelist_items,
API, колонка «Настройки» на странице прайслиста) — эти поля принадлежат
внешнему движку ценообразования и нигде не использовались в клиенте.
Сами колонки в qt_pricelist_items не трогаем — оставлены для обратной
совместимости с внешней системой.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-22 18:09:01 +03:00
co-authored by Claude Sonnet 5
parent 39e6fd5642
commit bea8d9453e
8 changed files with 90 additions and 128 deletions
+3 -6
View File
@@ -259,12 +259,9 @@ PK: lot_name
| lot_category | varchar(50) | | | 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 | | 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 | decimal(12,2) NOT NULL | |
| price_method | varchar(20) | synced to local and shown in the pricelist detail "Настройки" column | | 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_period_days | bigint DEFAULT 90 | synced to local, same as above |
| price_coefficient | decimal(5,2) DEFAULT 0 | synced to local, same as above | 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.
| 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` |
Fields QF never populates and does not model: `AvailableQty`/`Partnumbers` (removed from `models.PricelistItem` and `LocalPricelistItem` — no writer ever set them; always empty). Fields QF never populates and does not model: `AvailableQty`/`Partnumbers` (removed from `models.PricelistItem` and `LocalPricelistItem` — no writer ever set them; always empty).
+6 -10
View File
@@ -180,16 +180,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
resultItems := make([]gin.H, 0, len(items)) resultItems := make([]gin.H, 0, len(items))
for _, item := range items { for _, item := range items {
resultItems = append(resultItems, gin.H{ resultItems = append(resultItems, gin.H{
"id": item.ID, "id": item.ID,
"lot_name": item.LotName, "lot_name": item.LotName,
"lot_description": item.LotDescription, "lot_description": item.LotDescription,
"price": item.Price, "price": item.Price,
"category": item.LotCategory, "category": item.LotCategory,
"price_method": item.PriceMethod, "price_quality": item.PriceQuality,
"price_period_days": item.PricePeriodDays,
"price_coefficient": item.PriceCoefficient,
"manual_price": item.ManualPrice,
"meta_prices": item.MetaPrices,
}) })
} }
+13 -21
View File
@@ -332,32 +332,24 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem // PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem { func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
return &LocalPricelistItem{ return &LocalPricelistItem{
PricelistID: localPricelistID, PricelistID: localPricelistID,
LotName: models.NormalizeLotName(item.LotName), LotName: models.NormalizeLotName(item.LotName),
LotCategory: item.LotCategory, LotCategory: item.LotCategory,
LotDescription: item.LotDescription, LotDescription: item.LotDescription,
Price: item.Price, Price: item.Price,
PriceMethod: item.PriceMethod, PriceQuality: item.PriceQuality,
PricePeriodDays: item.PricePeriodDays,
PriceCoefficient: item.PriceCoefficient,
ManualPrice: item.ManualPrice,
MetaPrices: item.MetaPrices,
} }
} }
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem // LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem { func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
return &models.PricelistItem{ return &models.PricelistItem{
ID: local.ID, ID: local.ID,
PricelistID: serverPricelistID, PricelistID: serverPricelistID,
LotName: local.LotName, LotName: local.LotName,
LotCategory: local.LotCategory, LotCategory: local.LotCategory,
LotDescription: local.LotDescription, LotDescription: local.LotDescription,
Price: local.Price, Price: local.Price,
PriceMethod: local.PriceMethod, PriceQuality: local.PriceQuality,
PricePeriodDays: local.PricePeriodDays,
PriceCoefficient: local.PriceCoefficient,
ManualPrice: local.ManualPrice,
MetaPrices: local.MetaPrices,
} }
} }
+41 -26
View File
@@ -130,13 +130,18 @@ var localMigrations = []localMigration{
run: addLocalProjectRentalEnabled, run: addLocalProjectRentalEnabled,
}, },
{ {
id: "2026_07_22_pricelist_items_price_settings", id: "2026_07_22_pricelist_items_add_description_drop_unused",
name: "Add lot_description and price settings columns to local_pricelist_items, drop unused available_qty/partnumbers", name: "Add lot_description to local_pricelist_items, drop unused available_qty/partnumbers",
run: addPricelistItemDescriptionAndPriceSettings, 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 { type columnInfo struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
} }
@@ -144,7 +149,33 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error {
var columns []columnInfo var columns []columnInfo
if err := tx.Raw(` if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_pricelist_items') 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 { `).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_pricelist_items columns: %w", err) return fmt.Errorf("check local_pricelist_items columns: %w", err)
} }
@@ -154,16 +185,13 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error {
have[c.Name] = true have[c.Name] = true
} }
if have["lot_description"] && have["price_method"] && have["price_period_days"] && if have["lot_description"] && !have["available_qty"] && !have["partnumbers"] {
have["price_coefficient"] && have["manual_price"] && have["meta_prices"] && slog.Info("local_pricelist_items already migrated to description-only schema")
!have["available_qty"] && !have["partnumbers"] {
slog.Info("local_pricelist_items already migrated to description/price-settings schema")
return nil return nil
} }
// SQLite: recreate table without the never-populated available_qty/partnumbers // SQLite: recreate table without the never-populated available_qty/partnumbers
// columns, adding lot_description and the price settings columns synced from // columns, adding lot_description (synced from qt_pricelist_items.lot_description).
// qt_pricelist_items (needed to render the pricelist detail UI correctly).
if err := tx.Exec(` if err := tx.Exec(`
CREATE TABLE local_pricelist_items_new ( CREATE TABLE local_pricelist_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -171,12 +199,7 @@ func addPricelistItemDescriptionAndPriceSettings(tx *gorm.DB) error {
lot_name TEXT NOT NULL, lot_name TEXT NOT NULL,
lot_category TEXT, lot_category TEXT,
lot_description TEXT, lot_description TEXT,
price REAL NOT NULL, price REAL NOT NULL
price_method TEXT,
price_period_days INTEGER DEFAULT 90,
price_coefficient REAL DEFAULT 0,
manual_price REAL,
meta_prices TEXT
) )
`).Error; err != nil { `).Error; err != nil {
return fmt.Errorf("create new local_pricelist_items table: %w", err) 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) 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(` if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
ON local_pricelist_items(pricelist_id, lot_name) 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) 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 return nil
} }
@@ -1227,4 +1243,3 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
slog.Info("deduplicated local_pricelist_items and added unique index") slog.Info("deduplicated local_pricelist_items and added unique index")
return nil return nil
} }
+3 -7
View File
@@ -197,13 +197,9 @@ type LocalPricelistItem struct {
LotDescription string `gorm:"column:lot_description" json:"lot_description,omitempty"` LotDescription string `gorm:"column:lot_description" json:"lot_description,omitempty"`
Price float64 `gorm:"not null" json:"price"` Price float64 `gorm:"not null" json:"price"`
// Price calculation settings, mirrored from qt_pricelist_items for display // PriceQuality mirrors qt_pricelist_items.price_quality: a 0-9 score set by
// in the pricelist detail UI (formatPriceSettings in pricelist_detail.html). // the external pricing tool. 7-9 good, 4-6 warning, 0-3 poor.
PriceMethod string `gorm:"column:price_method" json:"price_method,omitempty"` PriceQuality *int `gorm:"column:price_quality" json:"price_quality,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"`
} }
func (LocalPricelistItem) TableName() string { func (LocalPricelistItem) TableName() string {
+4 -7
View File
@@ -61,14 +61,11 @@ type PricelistItem struct {
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"` LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
LotDescription string `gorm:"column:lot_description;size:10000" json:"lot_description,omitempty"` LotDescription string `gorm:"column:lot_description;size:10000" json:"lot_description,omitempty"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"` 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 // PriceQuality is a 0-9 score set by the external pricing tool that builds
// pricelist detail UI as a compact "settings" summary next to each item. // the pricelist (based on quote recency/count per its own per-lot settings).
PricePeriodDays int `gorm:"default:90" json:"price_period_days"` // 7-9 = good, 4-6 = warning, 0-3 = poor. QF only displays it, never computes it.
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"` PriceQuality *int `gorm:"column:price_quality" json:"price_quality,omitempty"`
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"`
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
} }
func (PricelistItem) TableName() string { func (PricelistItem) TableName() string {
@@ -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;
+8 -51
View File
@@ -59,7 +59,6 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
<th id="th-settings" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr> </tr>
</thead> </thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200"> <tbody id="items-body" class="bg-white divide-y divide-gray-200">
@@ -90,7 +89,6 @@
const pl = await resp.json(); const pl = await resp.json();
currentSource = pl.source || ''; currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`; document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version; document.getElementById('pl-version').textContent = pl.version;
@@ -133,7 +131,6 @@
const resp = await fetch(url); const resp = await fetch(url);
const data = await resp.json(); const data = await resp.json();
currentSource = data.source || currentSource; currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []); renderItems(data.items || []);
renderItemsPagination(data.total_count, data.page, data.per_page); renderItemsPagination(data.total_count, data.page, data.per_page);
@@ -158,12 +155,14 @@
} }
function itemsColspan() { function itemsColspan() {
return isStockSource() ? 4 : 5; return 4;
} }
function toggleWarehouseColumns() { function formatPriceQualityWarning(quality) {
const stock = isStockSource(); if (typeof quality !== 'number') return '';
document.getElementById('th-settings').classList.toggle('hidden', stock); if (quality >= 7) return '';
const color = quality >= 4 ? 'text-amber-500' : 'text-red-600';
return ` <span class="${color} font-bold" title="Качество цены: ${quality}/9">&#33;</span>`;
} }
function escapeHtml(text) { function escapeHtml(text) {
@@ -176,48 +175,6 @@
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
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('<span class="text-orange-600 font-medium">РУЧН</span>');
} 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('<span class="text-purple-600 font-medium">МЕТА</span>');
}
return settings.join(' | ') || '-';
}
function renderItems(items) { function renderItems(items) {
if (items.length === 0) { if (items.length === 0) {
document.getElementById('items-body').innerHTML = ` 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; let priceHtml = price;
if (!isWarehouseSource() && item.price_spread_pct > 0) { if (!isWarehouseSource() && item.price_spread_pct > 0) {
priceHtml += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`; priceHtml += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`;
} }
priceHtml += formatPriceQualityWarning(item.price_quality);
return ` return `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
@@ -257,7 +215,6 @@
</td> </td>
<td class="${p} text-sm text-gray-500" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td> <td class="${p} text-sm text-gray-500" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td>
<td class="${p} whitespace-nowrap text-right font-mono">${priceHtml}</td> <td class="${p} whitespace-nowrap text-right font-mono">${priceHtml}</td>
${!stock ? `<td class="${p} whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>` : ''}
</tr> </tr>
`; `;
}).join(''); }).join('');