Remove admin pricing stack and prepare v1.0.4 release

This commit is contained in:
2026-02-07 21:23:23 +03:00
parent 86ed26fdd6
commit d904db216f
28 changed files with 611 additions and 7584 deletions

View File

@@ -1,84 +0,0 @@
package main
import (
"flag"
"log"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
// Initialize repositories
statsRepo := repository.NewStatsRepository(db)
alertRepo := repository.NewAlertRepository(db)
componentRepo := repository.NewComponentRepository(db)
priceRepo := repository.NewPriceRepository(db)
// Initialize services
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
switch *cronJob {
case "alerts":
log.Println("Running alerts check...")
if err := alertService.CheckAndGenerateAlerts(); err != nil {
log.Printf("Error running alerts check: %v", err)
} else {
log.Println("Alerts check completed successfully")
}
case "update-prices":
log.Println("Recalculating all prices...")
updated, errors := pricingService.RecalculateAllPrices()
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
case "reset-counters":
log.Println("Resetting usage counters...")
if err := statsRepo.ResetWeeklyCounters(); err != nil {
log.Printf("Error resetting weekly counters: %v", err)
}
if err := statsRepo.ResetMonthlyCounters(); err != nil {
log.Printf("Error resetting monthly counters: %v", err)
}
log.Println("Usage counters reset completed")
case "update-popularity":
log.Println("Updating popularity scores...")
if err := statsRepo.UpdatePopularityScores(); err != nil {
log.Printf("Error updating popularity scores: %v", err)
} else {
log.Println("Popularity scores updated successfully")
}
default:
log.Println("No valid cron job specified. Available jobs:")
log.Println(" - alerts: Check and generate alerts")
log.Println(" - update-prices: Recalculate all prices")
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}

View File

@@ -1,160 +0,0 @@
package main
import (
"flag"
"log"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
log.Println("Connected to database")
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
if err := models.SeedCategories(db); err != nil {
log.Fatalf("Seeding categories failed: %v", err)
}
// Load categories for lookup
var categories []models.Category
db.Find(&categories)
categoryMap := make(map[string]uint)
for _, c := range categories {
categoryMap[c.Code] = c.ID
}
log.Printf("Loaded %d categories", len(categories))
// Get all lots
var lots []models.Lot
if err := db.Find(&lots).Error; err != nil {
log.Fatalf("Failed to load lots: %v", err)
}
log.Printf("Found %d lots to import", len(lots))
// Import each lot
var imported, skipped, updated int
for _, lot := range lots {
category, model := ParsePartNumber(lot.LotName)
var categoryID *uint
if id, ok := categoryMap[category]; ok && id > 0 {
categoryID = &id
} else {
// Try to find by prefix match
for code, id := range categoryMap {
if strings.HasPrefix(category, code) {
categoryID = &id
break
}
}
}
// Check if already exists
var existing models.LotMetadata
result := db.Where("lot_name = ?", lot.LotName).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Check if there are prices in the last 90 days
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
// Default to 90 days, but use "all time" (0) if no recent prices
periodDays := 90
if recentPriceCount == 0 {
periodDays = 0
}
// Create new
metadata := models.LotMetadata{
LotName: lot.LotName,
CategoryID: categoryID,
Model: model,
PricePeriodDays: periodDays,
}
if err := db.Create(&metadata).Error; err != nil {
log.Printf("Failed to create metadata for %s: %v", lot.LotName, err)
continue
}
imported++
} else if result.Error == nil {
// Update if needed
needsUpdate := false
if existing.Model == "" {
existing.Model = model
needsUpdate = true
}
if existing.CategoryID == nil {
existing.CategoryID = categoryID
needsUpdate = true
}
// Check if using default period (90 days) but no recent prices
if existing.PricePeriodDays == 90 {
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
if recentPriceCount == 0 {
existing.PricePeriodDays = 0
needsUpdate = true
}
}
if needsUpdate {
db.Save(&existing)
updated++
} else {
skipped++
}
}
}
log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped)
// Show final counts
var metadataCount int64
db.Model(&models.LotMetadata{}).Count(&metadataCount)
log.Printf("Total metadata records: %d", metadataCount)
}
// ParsePartNumber extracts category and model from lot_name
// Examples:
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}

View File

@@ -17,6 +17,7 @@ import (
"sort"
"strconv"
"strings"
syncpkg "sync"
"syscall"
"time"
@@ -31,9 +32,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
@@ -45,6 +43,7 @@ import (
var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
@@ -207,6 +206,15 @@ func main() {
os.Exit(1)
}
if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
slog.Warn("sync readiness check failed on startup", "error", readinessErr)
} else if readiness != nil && readiness.Blocked {
slog.Warn("sync readiness blocked on startup",
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
}
// Start background sync worker (will auto-skip when offline)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
@@ -446,8 +454,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
@@ -455,8 +461,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
@@ -465,13 +469,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
// Services
var pricingService *pricing.Service
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
var alertService *alerts.Service
var pricelistService *pricelist.Service
var stockImportService *services.StockImportService
var syncService *sync.Service
var projectService *services.ProjectService
@@ -479,22 +479,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncService = sync.NewService(connMgr, local)
if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, pricingService)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
stockImportService = services.NewStockImportService(mariaDB, pricelistService)
} else {
// In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
// In offline mode, we still need to create services that don't require DB.
componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, nil, local, pricingService)
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil, nil)
stockImportService = nil
}
// isOnline function for local-first architecture
@@ -526,20 +518,75 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
}
syncProjectsFromServer := func() {
if !connMgr.IsOnline() {
type pullState struct {
mu syncpkg.Mutex
running bool
lastStarted time.Time
}
triggerPull := func(label string, state *pullState, pullFn func() error) {
state.mu.Lock()
if state.running {
state.mu.Unlock()
return
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
slog.Warn("failed to sync projects from server", "error", err)
if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
state.mu.Unlock()
return
}
state.running = true
state.lastStarted = time.Now()
state.mu.Unlock()
go func() {
defer func() {
state.mu.Lock()
state.running = false
state.mu.Unlock()
}()
if err := pullFn(); err != nil {
slog.Warn("on-demand pull failed", "scope", label, "error", err)
}
}()
}
syncConfigurationsFromServer := func() {
var projectsPullState pullState
var configsPullState pullState
syncProjectsFromServer := func() error {
if !connMgr.IsOnline() {
return
return nil
}
_, _ = configService.ImportFromServer()
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping project pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
}
syncConfigurationsFromServer := func() error {
if !connMgr.IsOnline() {
return nil
}
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping configuration pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
_, err := configService.ImportFromServer()
if err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
}
// Use filepath.Join for cross-platform path compatibility
@@ -549,17 +596,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(
mariaDB,
pricingService,
alertService,
componentRepo,
priceRepo,
statsRepo,
stockImportService,
local.GetDBUser(),
)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
pricelistHandler := handlers.NewPricelistHandler(local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -615,28 +652,39 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false
var dbOK bool
var dbError string
includeCounts := c.Query("include_counts") == "true"
// Check if connection exists (fast check, no reconnect attempt)
// Fast status path: do not execute heavy COUNT queries unless requested.
status := connMgr.GetStatus()
if status.IsConnected {
// Already connected, safe to use
if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = true
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbOK = status.IsConnected
if !status.IsConnected {
dbError = "Database not connected (offline mode)"
if status.LastError != "" {
dbError = status.LastError
}
}
// Optional diagnostics mode with server table counts.
if includeCounts && status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
_ = db.Table("lot").Count(&lotCount)
_ = db.Table("lot_log").Count(&lotLogCount)
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
} else if err != nil {
dbOK = false
dbError = err.Error()
} else {
dbOK = false
dbError = "Database not connected (offline mode)"
}
} else {
lotCount = 0
lotLogCount = 0
metadataCount = 0
}
c.JSON(http.StatusOK, gin.H{
"connected": dbOK,
"error": dbError,
@@ -667,12 +715,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/configurator", webHandler.Configurator)
router.GET("/projects", webHandler.Projects)
router.GET("/projects/:uuid", webHandler.ProjectDetail)
router.GET("/pricelists", func(c *gin.Context) {
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
partials := router.Group("/partials")
@@ -716,22 +760,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists := api.Group("/pricelists")
{
pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest)
pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
pricelists.POST("", pricelistHandler.Create)
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete)
}
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
configs.GET("", func(c *gin.Context) {
syncConfigurationsFromServer()
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -1018,8 +1057,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects := api.Group("/projects")
{
projects.GET("", func(c *gin.Context) {
syncProjectsFromServer()
syncConfigurationsFromServer()
triggerPull("projects", &projectsPullState, syncProjectsFromServer)
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
@@ -1128,17 +1167,26 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
paged = filtered[start:end]
}
// Build per-project active config stats in one pass (avoid N+1 scans).
projectConfigCount := map[string]int{}
projectConfigTotal := map[string]float64{}
if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
for i := range localConfigs {
cfg := localConfigs[i]
if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
continue
}
projectUUID := *cfg.ProjectUUID
projectConfigCount[projectUUID]++
if cfg.TotalPrice != nil {
projectConfigTotal[projectUUID] += *cfg.TotalPrice
}
}
}
projectRows := make([]gin.H, 0, len(paged))
for i := range paged {
p := paged[i]
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
if err != nil {
configs = &services.ProjectConfigurationsResult{
ProjectUUID: p.UUID,
Configs: []models.Configuration{},
Total: 0,
}
}
projectRows = append(projectRows, gin.H{
"id": p.ID,
"uuid": p.UUID,
@@ -1149,8 +1197,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"is_system": p.IsSystem,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
"config_count": len(configs.Configs),
"total": configs.Total,
"config_count": projectConfigCount[p.UUID],
"total": projectConfigTotal[p.UUID],
})
}
@@ -1258,7 +1306,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
projects.GET("/:uuid/configs", func(c *gin.Context) {
syncConfigurationsFromServer()
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active")
if status != "active" && status != "archived" && status != "all" {
@@ -1318,35 +1366,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
}
// Pricing admin (public - RBAC disabled)
pricingAdmin := api.Group("/admin/pricing")
{
pricingAdmin.GET("/stats", pricingHandler.GetStats)
pricingAdmin.GET("/components", pricingHandler.ListComponents)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/lots", pricingHandler.ListLots)
pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable)
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules)
pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule)
pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
}
// Sync API (for offline mode)
syncAPI := api.Group("/sync")
{
syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/readiness", syncHandler.GetReadiness)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)