Files
PriceForge/internal/handlers/pricing.go
Mikhail Chusavitin f48615e8a9 Modularize Go files, extract JS to static, implement competitor pricelists
Go refactoring:
- Split handlers/pricing.go (2446→291 lines) into 5 focused files
- Split services/stock_import.go (1334→~400 lines) into stock_mappings.go + stock_parse.go
- Split services/sync/service.go (1290→~250 lines) into 3 files

JS extraction:
- Move all inline <script> blocks to web/static/js/ (6 files)
- Templates reduced: admin_pricing 2873→521, lot 1531→304, vendor_mappings 1063→169, etc.

Competitor pricelists (migrations 033-039):
- qt_competitors + partnumber_log_competitors tables
- Excel import with column mapping, dedup, bulk insert
- p/n→lot resolution via weighted_median, discount applied
- Unmapped p/ns written to qt_vendor_partnumber_seen
- Quote counts (unique/total) shown on /admin/competitors
- price_method="weighted_median", price_period_days=0 stored explicitly

Fix price_method/price_period_days for warehouse items:
- warehouse: weighted_avg, period=0
- competitor: weighted_median, period=0
- Removes misleading DB defaults (was: median/90)

Update bible: architecture.md, pricelist.md, history.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:44:10 +03:00

292 lines
8.6 KiB
Go

package handlers
import (
"net/http"
"sort"
"strings"
"git.mchus.pro/mchus/priceforge/internal/config"
"git.mchus.pro/mchus/priceforge/internal/dbutil"
"git.mchus.pro/mchus/priceforge/internal/models"
"git.mchus.pro/mchus/priceforge/internal/repository"
"git.mchus.pro/mchus/priceforge/internal/services"
"git.mchus.pro/mchus/priceforge/internal/services/alerts"
"git.mchus.pro/mchus/priceforge/internal/services/pricing"
"git.mchus.pro/mchus/priceforge/internal/tasks"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const maxStockImportFileSize int64 = 25 * 1024 * 1024
const maxVendorMappingsCSVImportFileSize int64 = 10 * 1024 * 1024
// calculateMedian returns the median of a sorted slice of prices
func calculateMedian(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
sort.Float64s(prices)
n := len(prices)
if n%2 == 0 {
return (prices[n/2-1] + prices[n/2]) / 2
}
return prices[n/2]
}
// calculateAverage returns the arithmetic mean of prices
func calculateAverage(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
var sum float64
for _, p := range prices {
sum += p
}
return sum / float64(len(prices))
}
// sortFloat64s sorts a slice of float64 in ascending order
func sortFloat64s(data []float64) {
sort.Float64s(data)
}
type PricingHandler struct {
db *gorm.DB
pricingService *pricing.Service
alertService *alerts.Service
componentRepo *repository.ComponentRepository
componentService *services.ComponentService
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
stockImportService *services.StockImportService
vendorMapService *services.VendorMappingService
partnumberBookService *services.PartnumberBookService
schedulerConfig config.SchedulerConfig
dbUsername string
taskManager *tasks.Manager
}
func NewPricingHandler(
db *gorm.DB,
pricingService *pricing.Service,
alertService *alerts.Service,
componentRepo *repository.ComponentRepository,
componentService *services.ComponentService,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
stockImportService *services.StockImportService,
vendorMapService *services.VendorMappingService,
partnumberBookService *services.PartnumberBookService,
schedulerConfig config.SchedulerConfig,
dbUsername string,
taskManager *tasks.Manager,
) *PricingHandler {
return &PricingHandler{
db: db,
pricingService: pricingService,
alertService: alertService,
componentRepo: componentRepo,
componentService: componentService,
priceRepo: priceRepo,
statsRepo: statsRepo,
stockImportService: stockImportService,
vendorMapService: vendorMapService,
partnumberBookService: partnumberBookService,
schedulerConfig: schedulerConfig,
dbUsername: dbUsername,
taskManager: taskManager,
}
}
// queryLotPrices fetches prices from lot_log with timeout and retry
func (h *PricingHandler) queryLotPrices(lotName string, periodDays int) ([]float64, error) {
var prices []float64
query := dbutil.DefaultQueryTimeout(h.db)
var err error
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
if periodDays > 0 {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, periodDays).Pluck("price", &prices).Error
})
} else {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &prices).Error
})
}
} else {
if periodDays > 0 {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, periodDays).Pluck("price", &prices).Error
})
} else {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices).Error
})
}
}
return prices, err
}
// queryMultipleLotPrices fetches prices from multiple lots with timeout and retry
func (h *PricingHandler) queryMultipleLotPrices(lotNames []string, periodDays int) ([]float64, error) {
var prices []float64
query := dbutil.DefaultQueryTimeout(h.db)
var err error
if periodDays > 0 {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotNames, periodDays).Pluck("price", &prices).Error
})
} else {
err = query.Execute(func(db *gorm.DB) error {
return db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, lotNames).Pluck("price", &prices).Error
})
}
return prices, err
}
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
result := make(map[string][]string)
// Get all components with meta_prices
var metaComponents []models.LotMetadata
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
// Build reverse lookup: which components are used in which meta-articles
for _, meta := range metaComponents {
sources := strings.Split(meta.MetaPrices, ",")
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
// Handle wildcard patterns
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lotName := range lotNames {
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
} else {
// Direct match
for _, lotName := range lotNames {
if lotName == source && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
}
}
}
return result
}
// expandMetaPrices expands meta_prices string to list of actual lot names.
// baseLot is always included as a source, then meta sources are added without duplicates.
func (h *PricingHandler) expandMetaPrices(metaPrices, baseLot string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
if baseLot != "" {
result = append(result, baseLot)
seen[baseLot] = true
}
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots
prefix := strings.TrimSuffix(source, "*")
var matchingLots []string
h.db.Model(&models.LotMetadata{}).
Where("lot_name LIKE ?", prefix+"%").
Pluck("lot_name", &matchingLots)
for _, lot := range matchingLots {
if !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries).
// baseLot is always included as a source, then meta sources are added without duplicates.
func expandMetaPricesWithCache(metaPrices, baseLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
if baseLot != "" {
result = append(result, baseLot)
seen[baseLot] = true
}
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots from cache
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}
func (h *PricingHandler) GetStats(c *gin.Context) {
// Check if we're in offline mode
if h.statsRepo == nil || h.alertService == nil {
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": 0,
"top_components": []interface{}{},
"trending_components": []interface{}{},
"offline": true,
})
return
}
newAlerts, _ := h.alertService.GetNewAlertsCount()
topComponents, _ := h.statsRepo.GetTopComponents(10)
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": newAlerts,
"top_components": topComponents,
"trending_components": trendingComponents,
})
}