Add Phase 2: Local SQLite database with sync functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
134
internal/handlers/pricelist.go
Normal file
134
internal/handlers/pricelist.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
)
|
||||
|
||||
type PricelistHandler struct {
|
||||
service *pricelist.Service
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
|
||||
return &PricelistHandler{service: service, localDB: localDB}
|
||||
}
|
||||
|
||||
// List returns all pricelists with pagination
|
||||
func (h *PricelistHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
pricelists, total, err := h.service.List(page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": pricelists,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a single pricelist by ID
|
||||
func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
pl, err := h.service.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pl)
|
||||
}
|
||||
|
||||
// Create creates a new pricelist from current prices
|
||||
func (h *PricelistHandler) Create(c *gin.Context) {
|
||||
// Get the database username as the creator
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
|
||||
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pl)
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (h *PricelistHandler) Delete(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||||
}
|
||||
|
||||
// GetItems returns items for a pricelist with pagination
|
||||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||
search := c.Query("search")
|
||||
|
||||
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// CanWrite returns whether the current user can create pricelists
|
||||
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
|
||||
}
|
||||
|
||||
// GetLatest returns the most recent active pricelist
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
pl, err := h.service.GetLatestActive()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pl)
|
||||
}
|
||||
196
internal/handlers/setup.go
Normal file
196
internal/handlers/setup.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
templates["setup.html"] = tmpl
|
||||
|
||||
return &SetupHandler{
|
||||
localDB: localDB,
|
||||
templates: templates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowSetup renders the database setup form
|
||||
func (h *SetupHandler) ShowSetup(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
// Get existing settings if any
|
||||
settings, _ := h.localDB.GetSettings()
|
||||
|
||||
data := gin.H{
|
||||
"Settings": settings,
|
||||
}
|
||||
|
||||
tmpl := h.templates["setup.html"]
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the database connection without saving
|
||||
func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
host := c.PostForm("host")
|
||||
portStr := c.PostForm("port")
|
||||
database := c.PostForm("database")
|
||||
user := c.PostForm("user")
|
||||
password := c.PostForm("password")
|
||||
|
||||
port := 3306
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Failed to get database handle: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Ping failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for required tables
|
||||
var lotCount int64
|
||||
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
canWrite := testWritePermission(db)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"lot_count": lotCount,
|
||||
"can_write": canWrite,
|
||||
"message": fmt.Sprintf("Connected successfully! Found %d components.", lotCount),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveConnection saves the connection settings and signals restart
|
||||
func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
host := c.PostForm("host")
|
||||
portStr := c.PostForm("port")
|
||||
database := c.PostForm("database")
|
||||
user := c.PostForm("user")
|
||||
password := c.PostForm("password")
|
||||
|
||||
port := 3306
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
// Test connection first
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
// Save settings
|
||||
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Failed to save settings: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved. Please restart the application.",
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatus returns the current setup status
|
||||
func (h *SetupHandler) GetStatus(c *gin.Context) {
|
||||
hasSettings := h.localDB.HasSettings()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": hasSettings,
|
||||
})
|
||||
}
|
||||
|
||||
func testWritePermission(db *gorm.DB) bool {
|
||||
// Simple check: try to create a temporary table and drop it
|
||||
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
|
||||
|
||||
// Try to create a test table
|
||||
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Drop it immediately
|
||||
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
|
||||
|
||||
return true
|
||||
}
|
||||
217
internal/handlers/sync.go
Normal file
217
internal/handlers/sync.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
mariaDB *gorm.DB
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB) *SyncHandler {
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
mariaDB: mariaDB,
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// GetStatus returns current sync status
|
||||
// GET /api/sync/status
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get sync times
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get counts
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get server pricelist count if online
|
||||
serverPricelists := 0
|
||||
needPricelistSync := false
|
||||
if isOnline {
|
||||
status, err := h.syncService.GetStatus()
|
||||
if err == nil {
|
||||
serverPricelists = status.ServerPricelists
|
||||
needPricelistSync = status.NeedsSync
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncResultResponse represents sync operation result
|
||||
type SyncResultResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Synced int `json:"synced"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncComponents syncs components from MariaDB to local SQLite
|
||||
// POST /api/sync/components
|
||||
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Components synced successfully",
|
||||
Synced: result.TotalSynced,
|
||||
Duration: result.Duration.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||
// POST /api/sync/pricelists
|
||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
synced, err := h.syncService.SyncPricelists()
|
||||
if err != nil {
|
||||
slog.Error("pricelist sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Pricelists synced successfully",
|
||||
Synced: synced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SyncAll syncs both components and pricelists
|
||||
// POST /api/sync/all
|
||||
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var componentsSynced, pricelistsSynced int
|
||||
|
||||
// Sync components
|
||||
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Component sync failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
// Sync pricelists
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
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(),
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncAllResponse{
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
}
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
func (h *SyncHandler) checkOnline() bool {
|
||||
sqlDB, err := h.mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html"}
|
||||
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)
|
||||
@@ -154,6 +154,14 @@ func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
||||
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
// Partials for htmx
|
||||
|
||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
|
||||
268
internal/localdb/components.go
Normal file
268
internal/localdb/components.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentSyncResult contains statistics from component sync
|
||||
type ComponentSyncResult struct {
|
||||
TotalSynced int
|
||||
NewCount int
|
||||
UpdateCount int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Query to join lot with qt_lot_metadata
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
Category *string
|
||||
Model *string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
var rows []componentRow
|
||||
err := mariaDB.Raw(`
|
||||
SELECT
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model,
|
||||
m.current_price
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
||||
ORDER BY l.lot_name
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
slog.Warn("no components found in MariaDB")
|
||||
return &ComponentSyncResult{
|
||||
Duration: time.Since(startTime),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get existing local components for comparison
|
||||
existingMap := make(map[string]bool)
|
||||
var existing []LocalComponent
|
||||
if err := l.db.Find(&existing).Error; err != nil {
|
||||
return nil, fmt.Errorf("reading existing local components: %w", err)
|
||||
}
|
||||
for _, c := range existing {
|
||||
existingMap[c.LotName] = true
|
||||
}
|
||||
|
||||
// Prepare components for batch insert/update
|
||||
syncTime := time.Now()
|
||||
components := make([]LocalComponent, 0, len(rows))
|
||||
newCount := 0
|
||||
|
||||
for _, row := range rows {
|
||||
category := ""
|
||||
if row.Category != nil {
|
||||
category = *row.Category
|
||||
} else {
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(row.LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
model := ""
|
||||
if row.Model != nil {
|
||||
model = *row.Model
|
||||
}
|
||||
|
||||
comp := LocalComponent{
|
||||
LotName: row.LotName,
|
||||
LotDescription: row.LotDescription,
|
||||
Category: category,
|
||||
Model: model,
|
||||
CurrentPrice: row.CurrentPrice,
|
||||
SyncedAt: syncTime,
|
||||
}
|
||||
components = append(components, comp)
|
||||
|
||||
if !existingMap[row.LotName] {
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Use transaction for bulk upsert
|
||||
err = l.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete all existing and insert new (simpler than upsert for SQLite)
|
||||
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
|
||||
return fmt.Errorf("clearing local components: %w", err)
|
||||
}
|
||||
|
||||
// Batch insert
|
||||
batchSize := 500
|
||||
for i := 0; i < len(components); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(components) {
|
||||
end = len(components)
|
||||
}
|
||||
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
|
||||
return fmt.Errorf("inserting components batch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
if err := l.SetComponentSyncTime(syncTime); err != nil {
|
||||
slog.Warn("failed to update component sync time", "error", err)
|
||||
}
|
||||
|
||||
result := &ComponentSyncResult{
|
||||
TotalSynced: len(components),
|
||||
NewCount: newCount,
|
||||
UpdateCount: len(components) - newCount,
|
||||
Duration: time.Since(startTime),
|
||||
}
|
||||
|
||||
slog.Info("components synced",
|
||||
"total", result.TotalSynced,
|
||||
"new", result.NewCount,
|
||||
"updated", result.UpdateCount,
|
||||
"duration", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchLocalComponents searches components in local cache by query string
|
||||
// Searches in lot_name, lot_description, category, and model fields
|
||||
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var components []LocalComponent
|
||||
|
||||
if query == "" {
|
||||
// Return all components with limit
|
||||
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
// Search with LIKE on multiple fields
|
||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
err := l.db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
).Order("lot_name").Limit(limit).Find(&components).Error
|
||||
|
||||
return components, err
|
||||
}
|
||||
|
||||
// SearchLocalComponentsByCategory searches components by category and optional query
|
||||
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var components []LocalComponent
|
||||
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
|
||||
|
||||
if query != "" {
|
||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||
db = db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
err := db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
// GetLocalComponent returns a single component by lot_name
|
||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
var component LocalComponent
|
||||
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from local components
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
var categories []string
|
||||
err := l.db.Model(&LocalComponent{}).
|
||||
Distinct("category").
|
||||
Where("category != ''").
|
||||
Order("category").
|
||||
Pluck("category", &categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// CountLocalComponents returns the total number of local components
|
||||
func (l *LocalDB) CountLocalComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountLocalComponentsByCategory returns component count by category
|
||||
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// GetComponentSyncTime returns the last component sync timestamp
|
||||
func (l *LocalDB) GetComponentSyncTime() *time.Time {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := l.db.Table("app_settings").
|
||||
Where("key = ?", "last_component_sync").
|
||||
First(&setting).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, setting.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// SetComponentSyncTime sets the last component sync timestamp
|
||||
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
||||
return l.db.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
// NeedComponentSync checks if component sync is needed (older than specified hours)
|
||||
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
syncTime := l.GetComponentSyncTime()
|
||||
if syncTime == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||
}
|
||||
87
internal/localdb/encryption.go
Normal file
87
internal/localdb/encryption.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
|
||||
func getEncryptionKey() []byte {
|
||||
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
|
||||
if key == "" {
|
||||
// Fallback to a machine-based key (hostname + fixed salt)
|
||||
hostname, _ := os.Hostname()
|
||||
key = hostname + "quoteforge-salt-2024"
|
||||
}
|
||||
// Hash to get exactly 32 bytes for AES-256
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext that was encrypted with Encrypt
|
||||
func Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
339
internal/localdb/localdb.go
Normal file
339
internal/localdb/localdb.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// ConnectionSettings stores MariaDB connection credentials
|
||||
type ConnectionSettings struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Host string `gorm:"not null"`
|
||||
Port int `gorm:"not null;default:3306"`
|
||||
Database string `gorm:"not null"`
|
||||
User string `gorm:"not null"`
|
||||
PasswordEncrypted string `gorm:"not null"` // AES encrypted
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (ConnectionSettings) TableName() string {
|
||||
return "connection_settings"
|
||||
}
|
||||
|
||||
// LocalDB manages the local SQLite database for settings
|
||||
type LocalDB struct {
|
||||
db *gorm.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// New creates a new LocalDB instance
|
||||
func New(dbPath string) (*LocalDB, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate all local tables
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalConfiguration{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("local SQLite database initialized", "path", dbPath)
|
||||
|
||||
return &LocalDB{
|
||||
db: db,
|
||||
path: dbPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
l.db.Model(&ConnectionSettings{}).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetSettings retrieves the connection settings with decrypted password
|
||||
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
|
||||
var settings ConnectionSettings
|
||||
if err := l.db.First(&settings).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting settings: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt password
|
||||
password, err := Decrypt(settings.PasswordEncrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypting password: %w", err)
|
||||
}
|
||||
settings.PasswordEncrypted = password // Return decrypted password in this field
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SaveSettings saves connection settings with encrypted password
|
||||
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
|
||||
// Encrypt password
|
||||
encrypted, err := Encrypt(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypting password: %w", err)
|
||||
}
|
||||
|
||||
settings := ConnectionSettings{
|
||||
ID: 1, // Always use ID=1 for single settings row
|
||||
Host: host,
|
||||
Port: port,
|
||||
Database: database,
|
||||
User: user,
|
||||
PasswordEncrypted: encrypted,
|
||||
}
|
||||
|
||||
// Upsert: create or update
|
||||
result := l.db.Save(&settings)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("saving settings: %w", result.Error)
|
||||
}
|
||||
|
||||
slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSettings removes all connection settings
|
||||
func (l *LocalDB) DeleteSettings() error {
|
||||
return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
|
||||
}
|
||||
|
||||
// GetDSN returns the MariaDB DSN string
|
||||
func (l *LocalDB) GetDSN() (string, error) {
|
||||
settings, err := l.GetSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
settings.User,
|
||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||
settings.Host,
|
||||
settings.Port,
|
||||
settings.Database,
|
||||
)
|
||||
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying gorm.DB for advanced operations
|
||||
func (l *LocalDB) DB() *gorm.DB {
|
||||
return l.db
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (l *LocalDB) Close() error {
|
||||
sqlDB, err := l.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// GetDBUser returns the database username from settings
|
||||
func (l *LocalDB) GetDBUser() string {
|
||||
settings, err := l.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return settings.User
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
|
||||
// SaveConfiguration saves a configuration to local SQLite
|
||||
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||
return l.db.Save(config).Error
|
||||
}
|
||||
|
||||
// GetConfigurations returns all local configurations
|
||||
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
err := l.db.Order("created_at DESC").Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
// GetConfigurationByUUID returns a configuration by UUID
|
||||
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
|
||||
var config LocalConfiguration
|
||||
err := l.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
return &config, err
|
||||
}
|
||||
|
||||
// DeleteConfiguration deletes a configuration by UUID
|
||||
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
||||
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
|
||||
}
|
||||
|
||||
// CountConfigurations returns the number of local configurations
|
||||
func (l *LocalDB) CountConfigurations() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalConfiguration{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// Pricelist methods
|
||||
|
||||
// GetLastSyncTime returns the last sync timestamp
|
||||
func (l *LocalDB) GetLastSyncTime() *time.Time {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := l.db.Table("app_settings").
|
||||
Where("key = ?", "last_pricelist_sync").
|
||||
First(&setting).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, setting.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// SetLastSyncTime sets the last sync timestamp
|
||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||
return l.db.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
// CountLocalPricelists returns the number of local pricelists
|
||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalPricelist{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
||||
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistByID returns a local pricelist by its local ID
|
||||
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.First(&pricelist, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||
return l.db.Save(pricelist).Error
|
||||
}
|
||||
|
||||
// GetLocalPricelists returns all local pricelists
|
||||
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
|
||||
var pricelists []LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pricelists, nil
|
||||
}
|
||||
|
||||
// CountLocalPricelistItems returns the number of items for a pricelist
|
||||
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Batch insert
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistItems returns items for a local pricelist
|
||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||
var items []LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
|
||||
First(&item).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||
Update("is_used", isUsed).Error
|
||||
}
|
||||
|
||||
// DeleteLocalPricelist deletes a pricelist and its items
|
||||
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||
// Delete items first
|
||||
if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete pricelist
|
||||
return l.db.Delete(&LocalPricelist{}, id).Error
|
||||
}
|
||||
122
internal/localdb/models.go
Normal file
122
internal/localdb/models.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppSetting stores application settings in local SQLite
|
||||
type AppSetting struct {
|
||||
Key string `gorm:"primaryKey" json:"key"`
|
||||
Value string `gorm:"not null" json:"value"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AppSetting) TableName() string {
|
||||
return "app_settings"
|
||||
}
|
||||
|
||||
// LocalConfigItem represents an item in a configuration
|
||||
type LocalConfigItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
// LocalConfigItems is a slice of LocalConfigItem that can be stored as JSON
|
||||
type LocalConfigItems []LocalConfigItem
|
||||
|
||||
func (c LocalConfigItems) Value() (driver.Value, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *LocalConfigItems) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*c = make(LocalConfigItems, 0)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("type assertion failed for LocalConfigItems")
|
||||
}
|
||||
return json.Unmarshal(bytes, c)
|
||||
}
|
||||
|
||||
func (c LocalConfigItems) Total() float64 {
|
||||
var total float64
|
||||
for _, item := range c {
|
||||
total += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// LocalConfiguration stores configurations in local SQLite
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
return "local_configurations"
|
||||
}
|
||||
|
||||
// LocalPricelist stores cached pricelists from server
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
|
||||
func (LocalPricelist) TableName() string {
|
||||
return "local_pricelists"
|
||||
}
|
||||
|
||||
// LocalPricelistItem stores pricelist items
|
||||
type LocalPricelistItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
}
|
||||
|
||||
func (LocalPricelistItem) TableName() string {
|
||||
return "local_pricelist_items"
|
||||
}
|
||||
|
||||
// LocalComponent stores cached components for offline search
|
||||
type LocalComponent struct {
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
}
|
||||
|
||||
func (LocalComponent) TableName() string {
|
||||
return "local_components"
|
||||
}
|
||||
43
internal/middleware/offline.go
Normal file
43
internal/middleware/offline.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OfflineDetector creates middleware that detects offline mode
|
||||
// Sets context values:
|
||||
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isOffline := !checkMariaDBOnline(mariaDB)
|
||||
|
||||
// Set context values for handlers
|
||||
c.Set("is_offline", isOffline)
|
||||
c.Set("localdb", local)
|
||||
|
||||
if isOffline {
|
||||
slog.Debug("offline mode detected - MariaDB unavailable")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// checkMariaDBOnline checks if MariaDB is accessible
|
||||
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
|
||||
sqlDB, err := mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AllModels returns all models for auto-migration
|
||||
func AllModels() []interface{} {
|
||||
@@ -12,12 +17,28 @@ func AllModels() []interface{} {
|
||||
&PriceOverride{},
|
||||
&PricingAlert{},
|
||||
&ComponentUsageStats{},
|
||||
&Pricelist{},
|
||||
&PricelistItem{},
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate runs auto-migration for all QuoteForge tables
|
||||
// Handles MySQL constraint errors gracefully for existing tables
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(AllModels()...)
|
||||
for _, model := range AllModels() {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
// Skip known MySQL constraint errors for existing tables
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Can't DROP") ||
|
||||
strings.Contains(errStr, "Duplicate key name") ||
|
||||
strings.Contains(errStr, "check that it exists") {
|
||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedCategories inserts default categories if not exist
|
||||
@@ -49,3 +70,35 @@ func SeedAdminUser(db *gorm.DB, passwordHash string) error {
|
||||
}
|
||||
return db.Create(admin).Error
|
||||
}
|
||||
|
||||
// EnsureDBUser creates or returns the user corresponding to the database connection username.
|
||||
// This is used when RBAC is disabled - configurations are owned by the DB user.
|
||||
// Returns the user ID that should be used for all operations.
|
||||
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
|
||||
if dbUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var user User
|
||||
err := db.Where("username = ?", dbUsername).First(&user).Error
|
||||
if err == nil {
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// User doesn't exist, create it
|
||||
user = User{
|
||||
Username: dbUsername,
|
||||
Email: dbUsername + "@db.local",
|
||||
PasswordHash: "-", // No password - this is a DB user, not an app user
|
||||
Role: RoleAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
58
internal/models/pricelist.go
Normal file
58
internal/models/pricelist.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Pricelist represents a versioned snapshot of prices
|
||||
type Pricelist struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
ItemCount int `gorm:"-" json:"item_count,omitempty"` // Virtual field for display
|
||||
}
|
||||
|
||||
func (Pricelist) TableName() string {
|
||||
return "qt_pricelists"
|
||||
}
|
||||
|
||||
// PricelistItem represents a single item in a pricelist
|
||||
type PricelistItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||
|
||||
// Price calculation settings (snapshot from qt_lot_metadata)
|
||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
||||
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"`
|
||||
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
|
||||
|
||||
// Virtual fields for display
|
||||
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
|
||||
Category string `gorm:"-" json:"category,omitempty"`
|
||||
}
|
||||
|
||||
func (PricelistItem) TableName() string {
|
||||
return "qt_pricelist_items"
|
||||
}
|
||||
|
||||
// PricelistSummary is used for list views
|
||||
type PricelistSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Notification string `json:"notification"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
IsActive bool `json:"is_active"`
|
||||
UsageCount int `json:"usage_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
@@ -73,3 +73,18 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter
|
||||
func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Count(&total)
|
||||
err := r.db.
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
259
internal/repository/pricelist.go
Normal file
259
internal/repository/pricelist.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PricelistRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||
return &PricelistRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
// Get item counts for each pricelist
|
||||
summaries := make([]models.PricelistSummary, len(pricelists))
|
||||
for i, pl := range pricelists {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
Version: pl.Version,
|
||||
Notification: pl.Notification,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
CreatedBy: pl.CreatedBy,
|
||||
IsActive: pl.IsActive,
|
||||
UsageCount: pl.UsageCount,
|
||||
ExpiresAt: pl.ExpiresAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, total, nil
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.First(&pricelist, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Get item count
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||
pricelist.ItemCount = int(itemCount)
|
||||
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetByVersion returns a pricelist by version string
|
||||
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// Create creates a new pricelist
|
||||
func (r *PricelistRepository) Create(pricelist *models.Pricelist) error {
|
||||
if err := r.db.Create(pricelist).Error; err != nil {
|
||||
return fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a pricelist
|
||||
func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
||||
if err := r.db.Save(pricelist).Error; err != nil {
|
||||
return fmt.Errorf("updating pricelist: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist if usage_count is 0
|
||||
func (r *PricelistRepository) Delete(id uint) error {
|
||||
pricelist, err := r.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pricelist.UsageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil {
|
||||
return fmt.Errorf("deleting pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Delete pricelist
|
||||
if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil {
|
||||
return fmt.Errorf("deleting pricelist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateItems batch inserts pricelist items
|
||||
func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use batch insert for better performance
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
return fmt.Errorf("batch inserting pricelist items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) {
|
||||
var total int64
|
||||
query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
||||
|
||||
if search != "" {
|
||||
query = query.Where("lot_name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting pricelist items: %w", err)
|
||||
}
|
||||
|
||||
var items []models.PricelistItem
|
||||
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Enrich with lot descriptions
|
||||
for i := range items {
|
||||
var lot models.Lot
|
||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||
items[i].LotDescription = lot.LotDescription
|
||||
}
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(items[i].LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
items[i].Category = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
var count int64
|
||||
if err := r.db.Model(&models.Pricelist{}).
|
||||
Where("version LIKE ?", today+"%").
|
||||
Count(&count).Error; err != nil {
|
||||
return "", fmt.Errorf("counting today's pricelists: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%03d", today, count+1), nil
|
||||
}
|
||||
|
||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||
func (r *PricelistRepository) CanWrite() bool {
|
||||
canWrite, _ := r.CanWriteDebug()
|
||||
return canWrite
|
||||
}
|
||||
|
||||
// CanWriteDebug checks write permission and returns debug info
|
||||
// Uses raw SQL with explicit columns to avoid schema mismatch issues
|
||||
func (r *PricelistRepository) CanWriteDebug() (bool, string) {
|
||||
// Check if table exists first
|
||||
var count int64
|
||||
if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil {
|
||||
return false, fmt.Sprintf("table check failed: %v", err)
|
||||
}
|
||||
|
||||
// Use raw SQL with only essential columns that always exist
|
||||
// This avoids GORM model validation and schema mismatch issues
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return false, fmt.Sprintf("begin tx failed: %v", tx.Error)
|
||||
}
|
||||
defer tx.Rollback() // Always rollback - this is just a permission test
|
||||
|
||||
testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000)
|
||||
|
||||
// Raw SQL insert with only core columns
|
||||
err := tx.Exec(`
|
||||
INSERT INTO qt_pricelists (version, created_by, is_active)
|
||||
VALUES (?, 'system', 1)
|
||||
`, testVersion).Error
|
||||
|
||||
if err != nil {
|
||||
// Check if it's a permission error vs other errors
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "INSERT command denied") ||
|
||||
strings.Contains(errStr, "Access denied") {
|
||||
return false, "no write permission"
|
||||
}
|
||||
return false, fmt.Sprintf("insert failed: %v", err)
|
||||
}
|
||||
|
||||
return true, "ok"
|
||||
}
|
||||
|
||||
// IncrementUsageCount increments the usage count for a pricelist
|
||||
func (r *PricelistRepository) IncrementUsageCount(id uint) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
|
||||
}
|
||||
|
||||
// DecrementUsageCount decrements the usage count for a pricelist
|
||||
func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
||||
}
|
||||
|
||||
// GetExpiredUnused returns pricelists that are expired and unused
|
||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()).
|
||||
Find(&pricelists).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting expired pricelists: %w", err)
|
||||
}
|
||||
return pricelists, nil
|
||||
}
|
||||
@@ -194,6 +194,149 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
|
||||
return s.configRepo.ListByUser(userID, offset, perPage)
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListAll(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
||||
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// DeleteNoAuth deletes configuration without ownership check
|
||||
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
// RenameNoAuth renames configuration without ownership check
|
||||
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID, // Use provided user ID
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
156
internal/services/pricelist/service.go
Normal file
156
internal/services/pricelist/service.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package pricelist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.PricelistRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
componentRepo: componentRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||
|
||||
pricelist := &models.Pricelist{
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(pricelist); err != nil {
|
||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Get all components with prices from qt_lot_metadata
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items := make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
// Clean up the pricelist if items creation fails
|
||||
s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("creating pricelist items: %w", err)
|
||||
}
|
||||
|
||||
pricelist.ItemCount = len(items)
|
||||
|
||||
slog.Info("pricelist created",
|
||||
"id", pricelist.ID,
|
||||
"version", pricelist.Version,
|
||||
"items", len(items),
|
||||
"created_by", createdBy,
|
||||
)
|
||||
|
||||
return pricelist, nil
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.List(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
return s.repo.CanWrite()
|
||||
}
|
||||
|
||||
// CanWriteDebug returns write permission status with debug info
|
||||
func (s *Service) CanWriteDebug() (bool, string) {
|
||||
return s.repo.CanWriteDebug()
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
return s.repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
func (s *Service) CleanupExpired() (int, error) {
|
||||
expired, err := s.repo.GetExpiredUnused()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, pl := range expired {
|
||||
if err := s.repo.Delete(pl.ID); err != nil {
|
||||
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
slog.Info("cleaned up expired pricelists", "deleted", deleted)
|
||||
return deleted, nil
|
||||
}
|
||||
215
internal/services/sync/service.go
Normal file
215
internal/services/sync/service.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
func NewService(pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatus represents the current sync status
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// Count server pricelists
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
localCount := s.localDB.CountLocalPricelists()
|
||||
|
||||
needsSync, _ := s.NeedSync()
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: len(serverPricelists),
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NeedSync checks if synchronization is needed
|
||||
// Returns true if there are new pricelists on server or last sync was >1 hour ago
|
||||
func (s *Service) NeedSync() (bool, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// If never synced, need sync
|
||||
if lastSync == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If last sync was more than 1 hour ago, suggest sync
|
||||
if time.Since(*lastSync) > time.Hour {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server
|
||||
latestServer, err := s.pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
// No local pricelists, need to sync
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist, need sync
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get all active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||
}
|
||||
|
||||
synced := 0
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Create local pricelist
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Version: pl.Version,
|
||||
Name: pl.Notification, // Using notification as name
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
synced++
|
||||
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Get local pricelist
|
||||
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting local pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Check if items already exist
|
||||
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
|
||||
if existingCount > 0 {
|
||||
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
// Get items from server
|
||||
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = localdb.LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
|
||||
}
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
}
|
||||
|
||||
// GetPricelistForOffline returns a pricelist suitable for offline use
|
||||
// If items are not synced, it will sync them first
|
||||
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
|
||||
// Ensure pricelist is synced
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
// Try to sync pricelists first
|
||||
if _, err := s.SyncPricelists(); err != nil {
|
||||
return nil, fmt.Errorf("syncing pricelists: %w", err)
|
||||
}
|
||||
|
||||
// Try again
|
||||
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pricelist not found on server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure items are synced
|
||||
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
|
||||
return nil, fmt.Errorf("syncing pricelist items: %w", err)
|
||||
}
|
||||
|
||||
return localPL, nil
|
||||
}
|
||||
Reference in New Issue
Block a user