- Drop `expected_discount_pct`, add `price_uplift DECIMAL(8,4) DEFAULT 1.3` to `qt_competitors` (migration 040); formula: effective_price = price / uplift - Extend `LoadLotMetrics` to return per-PN qty map (`pnQtysByLot`) - Add virtual fields `CompetitorNames`, `PriceSpreadPct`, `PartnumberQtys` to `PricelistItem`; populate via `enrichWarehouseItems` / `enrichCompetitorItems` - Competitor quotes filtered to qty > 0 before lot resolution - New "stock layout" on pricelist detail page for warehouse/competitor: Partnumbers column (PN + qty, only qty>0), Поставщик column, no Настройки/Доступно - Spread badge ±N% shown next to price for competitor rows - Bible updated: pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.5 KiB
Pricelists
Types
PriceForge supports three pricelist types (source field):
| Type | Data source | Status |
|---|---|---|
estimate |
qt_lot_metadata — snapshot of current prices |
Active |
warehouse |
stock_log — stock import data |
Active |
competitor |
Excel exports from competitor B2B portals | Active |
Estimate
Source: snapshot of current component prices from qt_lot_metadata.
Filter:
- Include only rows with
current_price > 0. - Exclude hidden rows (
is_hidden = 1).
Stores:
price_period_days— price calculation windowprice_coefficient— markup coefficientmanual_price— manually set pricemeta_prices— historical prices
Purpose: primary pricelist for calculations and estimates.
Categories: loaded from lot.lot_category for each component.
Warehouse
Source: stock_log (stock import records).
Rules (CRITICAL):
- Only mapped partnumbers — only positions with a record in
qt_partnumber_book_itemsare included. Unmapped partnumbers must not appear in any form. - No pricing settings — do not load from
lot_metadata: noprice_period_days,price_coefficient,manual_price,meta_prices. - Price method: always
weighted_avg(quantity-weighted average). - Field
price_methodalways contains"weighted_avg". - These are raw stock prices without additional processing.
Categories: loaded from lot.lot_category.
Implementation: internal/warehouse/snapshot.go
Competitor
Source: Excel exports from competitor B2B portals, uploaded manually via /admin/competitors.
Rules (CRITICAL):
- Only mapped partnumbers — only positions with a record in
qt_partnumber_book_itemsare included. Unmapped p/ns are counted and written toqt_vendor_partnumber_seen(source_type ="competitor:<code>"). - No pricing settings — do not load from
lot_metadata: noprice_period_days,price_coefficient,manual_price,meta_prices. - Price method: always
weighted_median(quantity-weighted median across all matched p/ns for a lot). - Field
price_methodalways contains"weighted_median",price_period_daysalways0. - Uplift: effective price =
price / price_uplift.price_upliftis a divisor > 1 stored per competitor; default1.3. Replaces the formerexpected_discount_pctfield. - Deduplication: if price and qty for a p/n are unchanged since last import — skip insert into
partnumber_log_competitors.
Key tables:
qt_competitors— competitor profiles (name, code,price_uplift DECIMAL(8,4) DEFAULT 1.3, column mapping JSON, currency)partnumber_log_competitors— historical quote log (competitor_id, partnumber, price USD, price local, qty, date)
Implementation: internal/services/competitor_import.go, internal/repository/competitor.go
Data Model
type Pricelist struct {
ID uint `gorm:"primaryKey"`
Version string
Source string // "estimate" | "warehouse" | "competitor"
// ...
}
type PricelistItem struct {
ID uint `gorm:"primaryKey"`
PricelistID uint
LotName string `gorm:"size:255"`
Price float64 `gorm:"type:decimal(12,2)"`
LotCategory *string `gorm:"column:lot_category;size:50" json:"category,omitempty"`
// Virtual fields (via JOIN or programmatically)
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
// Enriched per-source virtual fields (warehouse / competitor only)
CompetitorNames []string `gorm:"-" json:"competitor_names,omitempty"`
PriceSpreadPct *float64 `gorm:"-" json:"price_spread_pct,omitempty"`
PartnumberQtys map[string]float64 `gorm:"-" json:"partnumber_qtys,omitempty"`
}
gorm:"-:migration"— no DB column created, but mapped on SELECT.gorm:"-"— fully ignored in all DB operations.
Categories (lot_category)
- Source:
lot.lot_categorycolumn in tablelot. - NOT from
qt_lot_metadata. - NOT derived from LOT name.
- Persisted into
qt_pricelist_items.lot_categorywhen pricelist is created. - JSON field name:
"category". - JOIN with
lotis only needed forlot_description; category is already inqt_pricelist_items. - Default value when category is missing:
PART_.
Pricelist Creation (background task)
Creation runs via Task Manager. Handler returns task_id; frontend polls.
POST /api/pricelists/create
→ { "task_id": "uuid" }
→ polling GET /api/tasks/:id
→ { "status": "completed", "result": { "pricelist_id": 42 } }
Task type: TaskTypePricelistCreate.
Implementation:
- Service:
internal/services/pricelist/service.go - Warehouse calc:
internal/warehouse/snapshot.go - Handler:
internal/handlers/pricelist.go
Pricelist Deletion Guard
CountUsage(id) checks qt_configurations for references across all three pricelist columns:
pricelist_id, warehouse_pricelist_id, competitor_pricelist_id.
A pricelist is only deletable when all three counts are zero.
Competitor Pricelist
- Source:
competitor - Rebuilt via:
POST /api/competitors/pricelist(task type:competitor_import) - Logic: Latest quote per
(competitor_id, partnumber)across ALL active competitors → apply each competitor'sprice_uplift(divide price by uplift) →weighted_medianper lot → insert intoqt_pricelist_items - price_method:
weighted_median, price_period_days:0 - Quotes stored in
partnumber_log_competitors; unmapped p/ns recorded inqt_vendor_partnumber_seenwithsource_type = 'competitor:<code>'
Pricelist Detail Page — Display Layouts
Page: /pricelists/:id — web/templates/pricelist_detail.html + web/static/js/pricelist_detail.js
Two display layouts exist based on source:
Estimate layout (source = "estimate")
Columns: Артикул | Категория | Описание | Цена, $ | Настройки
- Description truncated to 60 chars.
- "Настройки" column shows per-row price adjustment controls.
- No partnumber or supplier column.
Stock layout (source = "warehouse" or "competitor")
Columns: Артикул | Категория | Описание | Partnumbers | Поставщик | Цена, $
- "Настройки" column is hidden.
- "Доступно" (available qty) column is hidden.
- Артикул column has word-break enabled (long lot names wrap).
- Description truncated to 30 chars.
Partnumbers column: shows only PNs with partnumber_qtys[pn] > 0, formatted as PN (qty шт.). Up to 4 shown, then +N ещё.
Поставщик column:
- Warehouse: grey text "склад".
- Competitor: blue badge(s) with competitor name(s) from
competitor_names.
Spread badge (competitor only): ±N% in amber next to the price, computed from price_spread_pct.
Per-source enrichment (internal/repository/pricelist.go)
enrichWarehouseItems: callswarehouse.LoadLotMetrics→ fillsPartnumbersandPartnumberQtys.enrichCompetitorItems: loads latest competitor quotes (qty > 0 only) → resolves to lots viaqt_partnumber_book_items→ fillsCompetitorNames,Partnumbers,PartnumberQtys,PriceSpreadPct.
LoadLotMetrics signature: (db, lotNames, latestOnly) → (qtyByLot, partnumbersByLot, pnQtysByLot, error).