Files
PriceForge/bible-local/pricelist.md
Mikhail Chusavitin c53c484bde Replace competitor discount with price_uplift; stock pricelist detail UI
- 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>
2026-03-13 12:58:41 +03:00

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 window
  • price_coefficient — markup coefficient
  • manual_price — manually set price
  • meta_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):

  1. Only mapped partnumbers — only positions with a record in qt_partnumber_book_items are included. Unmapped partnumbers must not appear in any form.
  2. No pricing settings — do not load from lot_metadata: no price_period_days, price_coefficient, manual_price, meta_prices.
  3. Price method: always weighted_avg (quantity-weighted average).
  4. Field price_method always contains "weighted_avg".
  5. 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):

  1. Only mapped partnumbers — only positions with a record in qt_partnumber_book_items are included. Unmapped p/ns are counted and written to qt_vendor_partnumber_seen (source_type = "competitor:<code>").
  2. No pricing settings — do not load from lot_metadata: no price_period_days, price_coefficient, manual_price, meta_prices.
  3. Price method: always weighted_median (quantity-weighted median across all matched p/ns for a lot).
  4. Field price_method always contains "weighted_median", price_period_days always 0.
  5. Uplift: effective price = price / price_uplift. price_uplift is a divisor > 1 stored per competitor; default 1.3. Replaces the former expected_discount_pct field.
  6. 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_category column in table lot.
  • NOT from qt_lot_metadata.
  • NOT derived from LOT name.
  • Persisted into qt_pricelist_items.lot_category when pricelist is created.
  • JSON field name: "category".
  • JOIN with lot is only needed for lot_description; category is already in qt_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's price_uplift (divide price by uplift) → weighted_median per lot → insert into qt_pricelist_items
  • price_method: weighted_median, price_period_days: 0
  • Quotes stored in partnumber_log_competitors; unmapped p/ns recorded in qt_vendor_partnumber_seen with source_type = 'competitor:<code>'

Pricelist Detail Page — Display Layouts

Page: /pricelists/:idweb/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: calls warehouse.LoadLotMetrics → fills Partnumbers and PartnumberQtys.
  • enrichCompetitorItems: loads latest competitor quotes (qty > 0 only) → resolves to lots via qt_partnumber_book_items → fills CompetitorNames, Partnumbers, PartnumberQtys, PriceSpreadPct.

LoadLotMetrics signature: (db, lotNames, latestOnly) → (qtyByLot, partnumbersByLot, pnQtysByLot, error).