package warehouse import ( "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"` Price float64 `gorm:"column:price"` Qty *float64 `gorm:"column:qty"` } var rows []stockRow if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil { return nil, err } resolver, err := lotmatch.NewLotResolverFromDB(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 } lot, _, err := resolver.Resolve(pn) if err != nil || strings.TrimSpace(lot) == "" { continue } weight := 0.0 if row.Qty != nil && *row.Qty > 0 { weight = *row.Qty } grouped[lot] = append(grouped[lot], 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 ensureCategoryExists(db *gorm.DB, code string) { 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 and partnumbers for selected lots. // If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date. func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) { qtyByLot := make(map[string]float64, len(lotNames)) partnumbersByLot := make(map[string][]string, len(lotNames)) if len(lotNames) == 0 { return qtyByLot, partnumbersByLot, 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, 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.LotPartnumber if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil { return nil, nil, err } for _, row := range mappingRows { addPartnumber(row.LotName, row.Partnumber) } type stockRow struct { Partnumber string `gorm:"column:partnumber"` Qty *float64 `gorm:"column:qty"` } var stockRows []stockRow if latestOnly { err = db.Raw(` SELECT sl.partnumber, 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, qty").Scan(&stockRows).Error } if err != nil { return nil, nil, err } for _, row := range stockRows { pn := strings.TrimSpace(row.Partnumber) if pn == "" { continue } lot, _, err := resolver.Resolve(pn) if err != nil { continue } if _, exists := lotSet[lot]; !exists { continue } if row.Qty != nil { qtyByLot[lot] += *row.Qty } addPartnumber(lot, pn) } 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, 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 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] } 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) }