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_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).
+1 -5
View File
@@ -185,11 +185,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
"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,
"price_quality": item.PriceQuality,
})
}
+2 -10
View File
@@ -337,11 +337,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
LotCategory: item.LotCategory,
LotDescription: item.LotDescription,
Price: item.Price,
PriceMethod: item.PriceMethod,
PricePeriodDays: item.PricePeriodDays,
PriceCoefficient: item.PriceCoefficient,
ManualPrice: item.ManualPrice,
MetaPrices: item.MetaPrices,
PriceQuality: item.PriceQuality,
}
}
@@ -354,10 +350,6 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
LotCategory: local.LotCategory,
LotDescription: local.LotDescription,
Price: local.Price,
PriceMethod: local.PriceMethod,
PricePeriodDays: local.PricePeriodDays,
PriceCoefficient: local.PriceCoefficient,
ManualPrice: local.ManualPrice,
MetaPrices: local.MetaPrices,
PriceQuality: local.PriceQuality,
}
}
+41 -26
View File
@@ -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
}
+3 -7
View File
@@ -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 {
+4 -7
View File
@@ -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 {
@@ -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-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>
</thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
@@ -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 ` <span class="${color} font-bold" title="Качество цены: ${quality}/9">&#33;</span>`;
}
function escapeHtml(text) {
@@ -176,48 +175,6 @@
.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) {
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 += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`;
}
priceHtml += formatPriceQualityWarning(item.price_quality);
return `
<tr class="hover:bg-gray-50">
@@ -257,7 +215,6 @@
</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>
${!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>
`;
}).join('');