package warehouse import ( "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 } type bundleComponent struct { LotName string Qty 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 } bundleMap, err := loadBundleComponents(db) if err != nil { return nil, err } refPrices, err := loadBundleRefPrices(db, bundleMap) 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) } lot, _, err := resolver.ResolveWithVendor(pn, vendor) if err != nil || strings.TrimSpace(lot) == "" { continue } weight := 0.0 if row.Qty != nil && *row.Qty > 0 { weight = *row.Qty } if comps, isBundle := bundleMap[lot]; isBundle && len(comps) > 0 { denominator := 0.0 for _, comp := range comps { denominator += comp.Qty * refPrices[comp.LotName] } if denominator <= 0 { continue } for _, comp := range comps { 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[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 loadBundleComponents(db *gorm.DB) (map[string][]bundleComponent, error) { type row struct { BundleLotName string `gorm:"column:bundle_lot_name"` LotName string `gorm:"column:lot_name"` Qty float64 `gorm:"column:qty"` } var rows []row if err := db.Table(models.LotBundleItem{}.TableName() + " bi"). Select("bi.bundle_lot_name, bi.lot_name, bi.qty"). Joins("INNER JOIN qt_lot_bundles b ON b.bundle_lot_name = bi.bundle_lot_name AND b.is_active = 1"). Scan(&rows).Error; err != nil { if isMissingTableError(err) { return map[string][]bundleComponent{}, nil } return nil, err } result := make(map[string][]bundleComponent) for _, row := range rows { bundleLot := strings.TrimSpace(row.BundleLotName) lot := strings.TrimSpace(row.LotName) if bundleLot == "" || lot == "" || row.Qty <= 0 { continue } result[bundleLot] = append(result[bundleLot], bundleComponent{LotName: lot, Qty: row.Qty}) } return result, nil } func loadBundleRefPrices(db *gorm.DB, bundleMap map[string][]bundleComponent) (map[string]float64, error) { ref := make(map[string]float64) if len(bundleMap) == 0 { return ref, nil } lotSet := make(map[string]struct{}) for _, comps := range bundleMap { for _, comp := range comps { if strings.TrimSpace(comp.LotName) != "" { lotSet[comp.LotName] = struct{}{} } } } if len(lotSet) == 0 { return ref, nil } lots := make([]string, 0, len(lotSet)) for lot := range lotSet { lots = append(lots, lot) } type metaRow struct { LotName string `gorm:"column:lot_name"` CurrentPrice *float64 `gorm:"column:current_price"` } var metaRows []metaRow if err := db.Table("qt_lot_metadata").Select("lot_name, current_price").Where("lot_name IN ?", lots).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 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 } var rows []struct { LotName string `gorm:"column:lot_name"` Price float64 `gorm:"column:price"` } if err := db.Table(models.PricelistItem{}.TableName()). Select("lot_name, price"). Where("pricelist_id = ? AND lot_name IN ?", pl.ID, lots). Scan(&rows).Error; err != nil { if isMissingTableError(err) { return ref, nil } return nil, err } 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) { 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 } bundleMap, err := loadBundleComponents(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"` 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, err } for _, row := range stockRows { pn := strings.TrimSpace(row.Partnumber) if pn == "" { continue } vendor := "" if row.Vendor != nil { vendor = strings.TrimSpace(*row.Vendor) } lot, _, err := resolver.ResolveWithVendor(pn, vendor) if err != nil { continue } if comps, isBundle := bundleMap[lot]; isBundle && len(comps) > 0 { for _, comp := range comps { if _, exists := lotSet[comp.LotName]; !exists { continue } if row.Qty != nil { qtyByLot[comp.LotName] += (*row.Qty) * comp.Qty } addPartnumber(comp.LotName, pn) } 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 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] }