Files
PriceForge/internal/warehouse/snapshot.go
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

447 lines
12 KiB
Go

package warehouse
import (
"encoding/json"
"errors"
"sort"
"strings"
"git.mchus.pro/mchus/priceforge/internal/lotmatch"
"git.mchus.pro/mchus/priceforge/internal/models"
"gorm.io/gorm"
)
type SnapshotItem struct {
LotName string
Price float64
PriceMethod string
Category string // Historical snapshot of lot_category
}
type weightedPricePoint struct {
price float64
weight float64
}
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Vendor *string `gorm:"column:vendor"`
Price float64 `gorm:"column:price"`
Qty *float64 `gorm:"column:qty"`
}
var rows []stockRow
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, vendor, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
return nil, err
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, err
}
refPrices, err := loadReferencePrices(db)
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Price <= 0 {
continue
}
vendor := ""
if row.Vendor != nil {
vendor = strings.TrimSpace(*row.Vendor)
}
mappedLots := resolver.MatchLotItemsWithVendor(pn, vendor)
if len(mappedLots) == 0 {
continue
}
weight := 0.0
if row.Qty != nil && *row.Qty > 0 {
weight = *row.Qty
}
if len(mappedLots) > 1 {
denominator := 0.0
for _, comp := range mappedLots {
denominator += comp.Qty * refPrices[comp.LotName]
}
if denominator <= 0 {
continue
}
for _, comp := range mappedLots {
ref := refPrices[comp.LotName]
if ref < 0 {
ref = 0
}
share := (comp.Qty * ref) / denominator
componentPrice := row.Price * share
componentWeight := weight
if componentWeight > 0 {
componentWeight *= comp.Qty
}
grouped[comp.LotName] = append(grouped[comp.LotName], weightedPricePoint{price: componentPrice, weight: componentWeight})
}
continue
}
grouped[mappedLots[0].LotName] = append(grouped[mappedLots[0].LotName], weightedPricePoint{price: row.Price, weight: weight})
}
items := make([]SnapshotItem, 0, len(grouped))
for lot, values := range grouped {
price := weightedAverage(values)
if price <= 0 {
continue
}
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_avg", Category: ""})
}
// Load categories for all lots in a single query
if len(items) > 0 {
lotNames := make([]string, 0, len(items))
lotToIdx := make(map[string]int, len(items))
for i, item := range items {
lotToIdx[item.LotName] = i
lotNames = append(lotNames, item.LotName)
}
var categories []struct {
LotName string
LotCategory string
}
if err := db.Table("lot").Select("lot_name, lot_category").Where("lot_name IN ?", lotNames).Scan(&categories).Error; err == nil {
for _, cat := range categories {
if idx, ok := lotToIdx[cat.LotName]; ok {
items[idx].Category = cat.LotCategory
}
}
}
defaultCategory := models.DefaultLotCategoryCode
missingCategoryLots := make([]string, 0)
for i := range items {
if strings.TrimSpace(items[i].Category) == "" {
items[i].Category = defaultCategory
missingCategoryLots = append(missingCategoryLots, items[i].LotName)
}
}
if len(missingCategoryLots) > 0 {
ensureCategoryExists(db, defaultCategory)
_ = db.Model(&models.Lot{}).
Where("lot_name IN ?", missingCategoryLots).
Update("lot_category", defaultCategory).Error
}
}
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
return items, nil
}
func loadReferencePrices(db *gorm.DB) (map[string]float64, error) {
ref := make(map[string]float64)
if db == nil {
return ref, nil
}
type metaRow struct {
LotName string `gorm:"column:lot_name"`
CurrentPrice *float64 `gorm:"column:current_price"`
}
var metaRows []metaRow
if db.Migrator().HasTable("qt_lot_metadata") {
if err := db.Table("qt_lot_metadata").Select("lot_name, current_price").Scan(&metaRows).Error; err != nil {
if isMissingTableError(err) {
return ref, nil
}
return nil, err
}
}
for _, row := range metaRows {
if row.CurrentPrice != nil && *row.CurrentPrice > 0 {
ref[row.LotName] = *row.CurrentPrice
}
}
// Fallback source: previous active warehouse pricelist.
var pl models.Pricelist
if db.Migrator().HasTable(models.Pricelist{}.TableName()) {
if err := db.Table(models.Pricelist{}.TableName()).
Where("source = ? AND is_active = 1", string(models.PricelistSourceWarehouse)).
Order("created_at DESC").
Limit(1).
First(&pl).Error; err != nil {
if isMissingTableError(err) || errors.Is(err, gorm.ErrRecordNotFound) {
return ref, nil
}
return ref, nil
}
} else {
return ref, nil
}
var rows []struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
}
if db.Migrator().HasTable(models.PricelistItem{}.TableName()) {
if err := db.Table(models.PricelistItem{}.TableName()).
Select("lot_name, price").
Where("pricelist_id = ?", pl.ID).
Scan(&rows).Error; err != nil {
if isMissingTableError(err) {
return ref, nil
}
return nil, err
}
} else {
return ref, nil
}
for _, row := range rows {
if row.Price > 0 {
if _, ok := ref[row.LotName]; !ok {
ref[row.LotName] = row.Price
}
}
}
return ref, nil
}
func isMissingTableError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist")
}
func ensureCategoryExists(db *gorm.DB, code string) {
if db == nil || !db.Migrator().HasTable(models.Category{}.TableName()) {
return
}
var count int64
if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 {
return
}
var maxOrder int
if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil {
return
}
_ = db.Create(&models.Category{
Code: code,
Name: code,
NameRu: code,
DisplayOrder: maxOrder + 1,
}).Error
}
// LoadLotMetrics returns stock qty, partnumbers, and per-PN qty for selected lots.
// If latestOnly is true, data from stock_log is calculated only for latest import date.
// Returns: qtyByLot, partnumbersByLot, pnQtysByLot (lot → pn → qty).
func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, map[string]map[string]float64, error) {
qtyByLot := make(map[string]float64, len(lotNames))
partnumbersByLot := make(map[string][]string, len(lotNames))
pnQtysByLot := make(map[string]map[string]float64, len(lotNames))
if len(lotNames) == 0 {
return qtyByLot, partnumbersByLot, pnQtysByLot, nil
}
lotSet := make(map[string]struct{}, len(lotNames))
for _, lot := range lotNames {
trimmed := strings.TrimSpace(lot)
if trimmed == "" {
continue
}
lotSet[trimmed] = struct{}{}
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, nil, nil, err
}
seenPN := make(map[string]map[string]struct{}, len(lotSet))
addPartnumber := func(lotName, partnumber string) {
lotName = strings.TrimSpace(lotName)
partnumber = strings.TrimSpace(partnumber)
if lotName == "" || partnumber == "" {
return
}
if _, ok := lotSet[lotName]; !ok {
return
}
if _, ok := seenPN[lotName]; !ok {
seenPN[lotName] = map[string]struct{}{}
}
key := strings.ToLower(partnumber)
if _, ok := seenPN[lotName][key]; ok {
return
}
seenPN[lotName][key] = struct{}{}
partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber)
}
var mappingRows []models.PartnumberBookItem
if db.Migrator().HasTable(models.PartnumberBookItem{}.TableName()) {
if err := db.Select("partnumber, lots_json").Find(&mappingRows).Error; err != nil && !isMissingTableError(err) {
return nil, nil, nil, err
}
}
for _, row := range mappingRows {
for _, lot := range parseCatalogLots(row.LotsJSON) {
addPartnumber(lot.LotName, row.Partnumber)
}
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Vendor *string `gorm:"column:vendor"`
Qty *float64 `gorm:"column:qty"`
}
var stockRows []stockRow
if latestOnly {
err = db.Raw(`
SELECT sl.partnumber, sl.vendor, sl.qty
FROM stock_log sl
INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date
`).Scan(&stockRows).Error
} else {
err = db.Table(models.StockLog{}.TableName()).Select("partnumber, vendor, qty").Scan(&stockRows).Error
}
if err != nil {
return nil, nil, nil, err
}
for _, row := range stockRows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
vendor := ""
if row.Vendor != nil {
vendor = strings.TrimSpace(*row.Vendor)
}
mappedLots := resolver.MatchLotItemsWithVendor(pn, vendor)
if len(mappedLots) == 0 {
continue
}
for _, lot := range mappedLots {
if _, exists := lotSet[lot.LotName]; !exists {
continue
}
lotQty := 0.0
if row.Qty != nil {
lotQty = (*row.Qty) * lot.Qty
qtyByLot[lot.LotName] += lotQty
}
addPartnumber(lot.LotName, pn)
if lotQty > 0 {
if pnQtysByLot[lot.LotName] == nil {
pnQtysByLot[lot.LotName] = make(map[string]float64)
}
pnQtysByLot[lot.LotName][pn] += lotQty
}
}
}
for lot := range partnumbersByLot {
sort.Slice(partnumbersByLot[lot], func(i, j int) bool {
return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j])
})
}
return qtyByLot, partnumbersByLot, pnQtysByLot, nil
}
func weightedMedian(values []weightedPricePoint) float64 {
if len(values) == 0 {
return 0
}
type pair struct {
price float64
weight float64
}
items := make([]pair, 0, len(values))
totalWeight := 0.0
prices := make([]float64, 0, len(values))
for _, v := range values {
if v.price <= 0 {
continue
}
prices = append(prices, v.price)
if v.weight > 0 {
items = append(items, pair{price: v.price, weight: v.weight})
totalWeight += v.weight
}
}
if totalWeight <= 0 {
return median(prices)
}
sort.Slice(items, func(i, j int) bool {
if items[i].price == items[j].price {
return items[i].weight < items[j].weight
}
return items[i].price < items[j].price
})
threshold := totalWeight / 2.0
acc := 0.0
for _, it := range items {
acc += it.weight
if acc >= threshold {
return it.price
}
}
return items[len(items)-1].price
}
func parseCatalogLots(lotsJSON string) []models.PartnumberBookLot {
var lots []models.PartnumberBookLot
if err := json.Unmarshal([]byte(strings.TrimSpace(lotsJSON)), &lots); err != nil {
return nil
}
out := make([]models.PartnumberBookLot, 0, len(lots))
for _, lot := range lots {
name := strings.TrimSpace(lot.LotName)
if name == "" || lot.Qty <= 0 {
continue
}
out = append(out, models.PartnumberBookLot{LotName: name, Qty: lot.Qty})
}
return out
}
func weightedAverage(values []weightedPricePoint) float64 {
if len(values) == 0 {
return 0
}
sumWeighted := 0.0
sumWeight := 0.0
prices := make([]float64, 0, len(values))
for _, v := range values {
if v.price <= 0 {
continue
}
prices = append(prices, v.price)
if v.weight > 0 {
sumWeighted += v.price * v.weight
sumWeight += v.weight
}
}
if sumWeight > 0 {
return sumWeighted / sumWeight
}
return median(prices)
}
func median(values []float64) float64 {
if len(values) == 0 {
return 0
}
cp := append([]float64(nil), values...)
sort.Float64s(cp)
n := len(cp)
if n%2 == 0 {
return (cp[n/2-1] + cp[n/2]) / 2
}
return cp[n/2]
}