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] }