- 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>
196 lines
7.5 KiB
Markdown
196 lines
7.5 KiB
Markdown
# 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
|
|
|
|
```go
|
|
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/: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`: 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)`.
|