Merge feature/phase2-sqlite-sync into main

This commit is contained in:
2026-02-03 22:04:17 +03:00
9 changed files with 170 additions and 67 deletions

21
assets_embed.go Normal file
View File

@@ -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")
}

View File

@@ -15,7 +15,7 @@ import (
"syscall" "syscall"
"time" "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/config"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/handlers"
@@ -28,6 +28,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -69,9 +70,14 @@ func main() {
// Load config for server settings (optional) // Load config for server settings (optional)
cfg, err := config.Load(*configPath) cfg, err := config.Load(*configPath)
if err != nil { if err != nil {
// Use defaults if config file doesn't exist if os.IsNotExist(err) {
slog.Info("config file not found, using defaults", "path", *configPath) // Use defaults if config file doesn't exist
cfg = &config.Config{} 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) setConfigDefaults(cfg)
@@ -238,7 +244,11 @@ func runSetupMode(local *localdb.LocalDB) {
router.Use(gin.Recovery()) router.Use(gin.Recovery())
staticPath := filepath.Join("web", "static") staticPath := filepath.Join("web", "static")
router.Static("/static", staticPath) if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Setup routes only // Setup routes only
router.GET("/", func(c *gin.Context) { router.GET("/", func(c *gin.Context) {
@@ -445,7 +455,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Static files (use filepath.Join for Windows compatibility) // Static files (use filepath.Join for Windows compatibility)
staticPath := filepath.Join("web", "static") staticPath := filepath.Join("web", "static")
router.Static("/static", staticPath) if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Health check // Health check
router.GET("/health", func(c *gin.Context) { router.GET("/health", func(c *gin.Context) {

View File

@@ -4,10 +4,10 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ComponentHandler struct { type ComponentHandler struct {
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
} }
// If offline mode (empty result), fallback to local components // 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{ localFilter := localdb.ComponentFilter{
Category: filter.Category, Category: filter.Category,
Search: filter.Search, Search: filter.Search,

View File

@@ -5,13 +5,15 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "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/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -34,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
// Load setup template (standalone, no base needed) // Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html") 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 { if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err) 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 // Always restart to properly initialize all services with the new connection
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Settings saved. Please restart the application to apply changes.", "message": "Settings saved. Please restart the application to apply changes.",
"restart_required": true, "restart_required": true,
}) })

View File

@@ -4,13 +4,15 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"time" "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/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
) )
// SyncHandler handles sync API endpoints // 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) { func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
// Load sync_status partial template // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") 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 { if err != nil {
return nil, err return nil, err
} }
@@ -40,14 +48,14 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status // SyncStatusResponse represents the sync status
type SyncStatusResponse struct { type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"` LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"` LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"` ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"` PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"` ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"` NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"` NeedPricelistSync bool `json:"need_pricelist_sync"`
} }
// GetStatus returns current sync status // GetStatus returns current sync status
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync, LastPricelistSync: lastPricelistSync,
IsOnline: isOnline, IsOnline: isOnline,
ComponentsCount: componentsCount, ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount, PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists, ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync, NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync, NeedPricelistSync: needPricelistSync,
}) })
} }
@@ -169,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
// SyncAllResponse represents result of full sync // SyncAllResponse represents result of full sync
type SyncAllResponse struct { type SyncAllResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
ComponentsSynced int `json:"components_synced"` ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"` PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"` Duration string `json:"duration"`
} }
// SyncAll syncs both components and pricelists // SyncAll syncs both components and pricelists
@@ -216,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil { if err != nil {
slog.Error("pricelist sync failed during full sync", "error", err) slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pricelist sync failed: " + err.Error(), "error": "Pricelist sync failed: " + err.Error(),
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
return return
@@ -294,9 +302,9 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
// SyncInfoResponse represents sync information // SyncInfoResponse represents sync information
type SyncInfoResponse struct { type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"` ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"` Errors []SyncError `json:"errors,omitempty"`
} }

View File

@@ -2,12 +2,14 @@ package handlers
import ( import (
"html/template" "html/template"
"os"
"path/filepath" "path/filepath"
"strconv" "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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type WebHandler struct { type WebHandler struct {
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html") 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 // Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Index page needs components_list.html as well // Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html") indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.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 { if err != nil {
return nil, err return nil, err
} }
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
partials := []string{"components_list.html"} partials := []string{"components_list.html"}
for _, partial := range partials { for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial) 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
case models.PriceMethodAverage: case models.PriceMethodAverage:
return CalculateAverage(prices), nil return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian: case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian: case models.PriceMethodMedian:
fallthrough fallthrough
default: default:
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
} }
return &PriceStats{ return &PriceStats{
QuoteCount: len(points), QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0), MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100), MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices), MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices), AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices), StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price, LatestPrice: points[0].Price,
LatestDate: points[0].Date, LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date, OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25), Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75), Percentile75: CalculatePercentile(prices, 75),
}, nil }, nil
} }

View File

@@ -9,14 +9,14 @@ import (
) )
var ( var (
ErrEmptyQuote = errors.New("quote cannot be empty") ErrEmptyQuote = errors.New("quote cannot be empty")
ErrComponentNotFound = errors.New("component not found") ErrComponentNotFound = errors.New("component not found")
ErrNoPriceAvailable = errors.New("no price available for component") ErrNoPriceAvailable = errors.New("no price available for component")
) )
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
pricingService *pricing.Service pricingService *pricing.Service
} }
@@ -43,11 +43,11 @@ type QuoteItem struct {
} }
type QuoteValidationResult struct { type QuoteValidationResult struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Items []QuoteItem `json:"items"` Items []QuoteItem `json:"items"`
Errors []string `json:"errors"` Errors []string `json:"errors"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
Total float64 `json:"total"` Total float64 `json:"total"`
} }
type QuoteRequest struct { type QuoteRequest struct {
@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
}
result := &QuoteValidationResult{ result := &QuoteValidationResult{
Valid: true, Valid: true,

View File

@@ -134,12 +134,16 @@ func (s *Service) SyncPricelists() (int, error) {
synced := 0 synced := 0
var latestLocalID uint var latestLocalID uint
var latestServerID uint
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
// Already synced, track latest // Already synced, track latest by server ID
latestLocalID = existing.ID if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = existing.ID
}
continue continue
} }
@@ -167,7 +171,10 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) 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++ synced++
} }