From 20056f3593f2eaf1e0923cdf5d4f7394c5836081 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Tue, 3 Feb 2026 21:58:02 +0300 Subject: [PATCH] Embed assets and fix offline/sync/pricing issues --- assets_embed.go | 21 +++++++++ cmd/server/main.go | 26 ++++++++--- internal/handlers/component.go | 10 ++++- internal/handlers/setup.go | 16 +++++-- internal/handlers/sync.go | 64 ++++++++++++++++------------ internal/handlers/web.go | 44 +++++++++++++++++-- internal/services/pricing/service.go | 24 +++++------ internal/services/quote.go | 19 +++++---- internal/services/sync/service.go | 13 ++++-- 9 files changed, 170 insertions(+), 67 deletions(-) create mode 100644 assets_embed.go diff --git a/assets_embed.go b/assets_embed.go new file mode 100644 index 0000000..9a1fec1 --- /dev/null +++ b/assets_embed.go @@ -0,0 +1,21 @@ +package quoteforge + +import ( + "embed" + "io/fs" +) + +// TemplatesFS contains HTML templates embedded into the binary. +// +//go:embed web/templates/*.html web/templates/partials/*.html +var TemplatesFS embed.FS + +// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary. +// +//go:embed web/static/* +var StaticFiles embed.FS + +// StaticFS returns a filesystem rooted at web/static for serving static assets. +func StaticFS() (fs.FS, error) { + return fs.Sub(StaticFiles, "web/static") +} diff --git a/cmd/server/main.go b/cmd/server/main.go index d95108f..eca792c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,7 +12,7 @@ import ( "syscall" "time" - "github.com/gin-gonic/gin" + qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/handlers" @@ -25,6 +25,7 @@ import ( "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" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -56,9 +57,14 @@ func main() { // Load config for server settings (optional) cfg, err := config.Load(*configPath) if err != nil { - // Use defaults if config file doesn't exist - slog.Info("config file not found, using defaults", "path", *configPath) - cfg = &config.Config{} + if os.IsNotExist(err) { + // Use defaults if config file doesn't exist + slog.Info("config file not found, using defaults", "path", *configPath) + cfg = &config.Config{} + } else { + slog.Error("failed to load config", "path", *configPath, "error", err) + os.Exit(1) + } } setConfigDefaults(cfg) @@ -211,7 +217,11 @@ func runSetupMode(local *localdb.LocalDB) { router := gin.New() router.Use(gin.Recovery()) - router.Static("/static", "web/static") + if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() { + router.Static("/static", "web/static") + } else if staticFS, err := qfassets.StaticFS(); err == nil { + router.StaticFS("/static", http.FS(staticFS)) + } // Setup routes only router.GET("/", func(c *gin.Context) { @@ -404,7 +414,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.Use(middleware.OfflineDetector(connMgr, local)) // Static files - router.Static("/static", "web/static") + if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() { + router.Static("/static", "web/static") + } else if staticFS, err := qfassets.StaticFS(); err == nil { + router.StaticFS("/static", http.FS(staticFS)) + } // Health check router.GET("/health", func(c *gin.Context) { diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 1609c27..dc1682c 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -4,10 +4,10 @@ import ( "net/http" "strconv" - "github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" + "github.com/gin-gonic/gin" ) type ComponentHandler struct { @@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) { } // If offline mode (empty result), fallback to local components - if result.Total == 0 && h.localDB != nil { + isOffline := false + if v, ok := c.Get("is_offline"); ok { + if b, ok := v.(bool); ok { + isOffline = b + } + } + if isOffline && result.Total == 0 && h.localDB != nil { localFilter := localdb.ComponentFilter{ Category: filter.Category, Search: filter.Search, diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 2a9c858..d2e71a5 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -5,13 +5,15 @@ import ( "html/template" "log/slog" "net/http" + "os" "path/filepath" "strconv" "time" - "github.com/gin-gonic/gin" + qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" + "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -34,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te // Load setup template (standalone, no base needed) setupPath := filepath.Join(templatesPath, "setup.html") - tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath) + var tmpl *template.Template + var err error + if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { + tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath) + } else { + tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html") + } if err != nil { return nil, fmt.Errorf("parsing setup template: %w", err) } @@ -196,8 +204,8 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { // Always restart to properly initialize all services with the new connection c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Settings saved. Please restart the application to apply changes.", + "success": true, + "message": "Settings saved. Please restart the application to apply changes.", "restart_required": true, }) diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 597483f..7f7fd00 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -4,13 +4,15 @@ import ( "html/template" "log/slog" "net/http" + "os" "path/filepath" "time" - "github.com/gin-gonic/gin" + qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/services/sync" + "github.com/gin-gonic/gin" ) // SyncHandler handles sync API endpoints @@ -25,7 +27,13 @@ type SyncHandler struct { func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) { // Load sync_status partial template partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") - tmpl, err := template.ParseFiles(partialPath) + var tmpl *template.Template + var err error + if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { + tmpl, err = template.ParseFiles(partialPath) + } else { + tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html") + } if err != nil { return nil, err } @@ -40,14 +48,14 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr // SyncStatusResponse represents the sync status type SyncStatusResponse struct { - LastComponentSync *time.Time `json:"last_component_sync"` - LastPricelistSync *time.Time `json:"last_pricelist_sync"` - IsOnline bool `json:"is_online"` - ComponentsCount int64 `json:"components_count"` - PricelistsCount int64 `json:"pricelists_count"` - ServerPricelists int `json:"server_pricelists"` - NeedComponentSync bool `json:"need_component_sync"` - NeedPricelistSync bool `json:"need_pricelist_sync"` + LastComponentSync *time.Time `json:"last_component_sync"` + LastPricelistSync *time.Time `json:"last_pricelist_sync"` + IsOnline bool `json:"is_online"` + ComponentsCount int64 `json:"components_count"` + PricelistsCount int64 `json:"pricelists_count"` + ServerPricelists int `json:"server_pricelists"` + NeedComponentSync bool `json:"need_component_sync"` + NeedPricelistSync bool `json:"need_pricelist_sync"` } // GetStatus returns current sync status @@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { needComponentSync := h.localDB.NeedComponentSync(24) c.JSON(http.StatusOK, SyncStatusResponse{ - LastComponentSync: lastComponentSync, - LastPricelistSync: lastPricelistSync, - IsOnline: isOnline, - ComponentsCount: componentsCount, - PricelistsCount: pricelistsCount, - ServerPricelists: serverPricelists, - NeedComponentSync: needComponentSync, - NeedPricelistSync: needPricelistSync, + LastComponentSync: lastComponentSync, + LastPricelistSync: lastPricelistSync, + IsOnline: isOnline, + ComponentsCount: componentsCount, + PricelistsCount: pricelistsCount, + ServerPricelists: serverPricelists, + NeedComponentSync: needComponentSync, + NeedPricelistSync: needPricelistSync, }) } @@ -169,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) { // SyncAllResponse represents result of full sync type SyncAllResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - ComponentsSynced int `json:"components_synced"` - PricelistsSynced int `json:"pricelists_synced"` - Duration string `json:"duration"` + Success bool `json:"success"` + Message string `json:"message"` + ComponentsSynced int `json:"components_synced"` + PricelistsSynced int `json:"pricelists_synced"` + Duration string `json:"duration"` } // SyncAll syncs both components and pricelists @@ -216,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { if err != nil { slog.Error("pricelist sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": "Pricelist sync failed: " + err.Error(), + "success": false, + "error": "Pricelist sync failed: " + err.Error(), "components_synced": componentsSynced, }) return @@ -294,9 +302,9 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) { // SyncInfoResponse represents sync information type SyncInfoResponse struct { - LastSyncAt *time.Time `json:"last_sync_at"` - IsOnline bool `json:"is_online"` - ErrorCount int `json:"error_count"` + LastSyncAt *time.Time `json:"last_sync_at"` + IsOnline bool `json:"is_online"` + ErrorCount int `json:"error_count"` Errors []SyncError `json:"errors,omitempty"` } diff --git a/internal/handlers/web.go b/internal/handlers/web.go index b4a9726..3bd63d7 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -2,12 +2,14 @@ package handlers import ( "html/template" + "os" "path/filepath" "strconv" - "github.com/gin-gonic/gin" + qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" + "github.com/gin-gonic/gin" ) type WebHandler struct { @@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer templates := make(map[string]*template.Template) basePath := filepath.Join(templatesPath, "base.html") + useDisk := false + if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { + useDisk = true + } // Load each page template with base simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} for _, page := range simplePages { pagePath := filepath.Join(templatesPath, page) - tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) + var tmpl *template.Template + var err error + if useDisk { + tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) + } else { + tmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/base.html", + "web/templates/"+page, + ) + } if err != nil { return nil, err } @@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer // Index page needs components_list.html as well indexPath := filepath.Join(templatesPath, "index.html") componentsListPath := filepath.Join(templatesPath, "components_list.html") - indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath) + var indexTmpl *template.Template + var err error + if useDisk { + indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath) + } else { + indexTmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/base.html", + "web/templates/index.html", + "web/templates/components_list.html", + ) + } if err != nil { return nil, err } @@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer partials := []string{"components_list.html"} for _, partial := range partials { partialPath := filepath.Join(templatesPath, partial) - tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath) + var tmpl *template.Template + var err error + if useDisk { + tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath) + } else { + tmpl, err = template.New("").Funcs(funcMap).ParseFS( + qfassets.TemplatesFS, + "web/templates/"+partial, + ) + } if err != nil { return nil, err } diff --git a/internal/services/pricing/service.go b/internal/services/pricing/service.go index a7ae6cf..bd4b0e5 100644 --- a/internal/services/pricing/service.go +++ b/internal/services/pricing/service.go @@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri case models.PriceMethodAverage: return CalculateAverage(prices), nil case models.PriceMethodWeightedMedian: - return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil + return CalculateWeightedMedian(points, periodDays), nil case models.PriceMethodMedian: fallthrough default: @@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er } return &PriceStats{ - QuoteCount: len(points), - MinPrice: CalculatePercentile(prices, 0), - MaxPrice: CalculatePercentile(prices, 100), - MedianPrice: CalculateMedian(prices), - AveragePrice: CalculateAverage(prices), - StdDeviation: CalculateStdDev(prices), - LatestPrice: points[0].Price, - LatestDate: points[0].Date, - OldestDate: points[len(points)-1].Date, - Percentile25: CalculatePercentile(prices, 25), - Percentile75: CalculatePercentile(prices, 75), + QuoteCount: len(points), + MinPrice: CalculatePercentile(prices, 0), + MaxPrice: CalculatePercentile(prices, 100), + MedianPrice: CalculateMedian(prices), + AveragePrice: CalculateAverage(prices), + StdDeviation: CalculateStdDev(prices), + LatestPrice: points[0].Price, + LatestDate: points[0].Date, + OldestDate: points[len(points)-1].Date, + Percentile25: CalculatePercentile(prices, 25), + Percentile75: CalculatePercentile(prices, 75), }, nil } diff --git a/internal/services/quote.go b/internal/services/quote.go index b748c38..9b3f17c 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -9,14 +9,14 @@ import ( ) var ( - ErrEmptyQuote = errors.New("quote cannot be empty") + ErrEmptyQuote = errors.New("quote cannot be empty") ErrComponentNotFound = errors.New("component not found") ErrNoPriceAvailable = errors.New("no price available for component") ) type QuoteService struct { - componentRepo *repository.ComponentRepository - statsRepo *repository.StatsRepository + componentRepo *repository.ComponentRepository + statsRepo *repository.StatsRepository pricingService *pricing.Service } @@ -43,11 +43,11 @@ type QuoteItem struct { } type QuoteValidationResult struct { - Valid bool `json:"valid"` - Items []QuoteItem `json:"items"` - Errors []string `json:"errors"` - Warnings []string `json:"warnings"` - Total float64 `json:"total"` + Valid bool `json:"valid"` + Items []QuoteItem `json:"items"` + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Total float64 `json:"total"` } type QuoteRequest struct { @@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation if len(req.Items) == 0 { return nil, ErrEmptyQuote } + if s.componentRepo == nil || s.pricingService == nil { + return nil, errors.New("offline mode: quote calculation not available") + } result := &QuoteValidationResult{ Valid: true, diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 9c0bba7..a08e14a 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -134,12 +134,16 @@ func (s *Service) SyncPricelists() (int, error) { synced := 0 var latestLocalID uint + var latestServerID uint for _, pl := range serverPricelists { // Check if pricelist already exists locally existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) if existing != nil { - // Already synced, track latest - latestLocalID = existing.ID + // Already synced, track latest by server ID + if pl.ID > latestServerID { + latestServerID = pl.ID + latestLocalID = existing.ID + } continue } @@ -167,7 +171,10 @@ func (s *Service) SyncPricelists() (int, error) { slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) } - latestLocalID = localPL.ID + if pl.ID > latestServerID { + latestServerID = pl.ID + latestLocalID = localPL.ID + } synced++ }