Remove admin pricing stack and prepare v1.0.4 release
This commit is contained in:
228
cmd/qfs/main.go
228
cmd/qfs/main.go
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user