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>
292 lines
8.6 KiB
Go
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,
|
|
})
|
|
}
|