Add offline RefreshPrices, fix sync bugs, implement auto-restart
- Implement RefreshPrices for local-first mode - Update prices from local_components.current_price cache - Graceful degradation when component not found - Add PriceUpdatedAt timestamp to LocalConfiguration model - Support both authenticated and no-auth price refresh - Fix sync duplicate entry bug - pushConfigurationUpdate now ensures server_id exists before update - Fetch from LocalConfiguration.ServerID or search on server if missing - Update local config with server_id after finding - Add application auto-restart after settings save - Implement restartProcess() using syscall.Exec - Setup handler signals restart via channel - Setup page polls /health endpoint and redirects when ready - Add "Back" button on setup page when settings exist - Fix setup handler password handling - Use PasswordEncrypted field consistently - Support empty password by using saved value - Improve sync status handling - Add fallback for is_offline check in SyncStatusPartial - Enhance background sync logging with prefixes - Update CLAUDE.md documentation - Mark Phase 2.5 tasks as complete - Add UI Improvements section with future tasks - Update SQLite tables documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
49
CLAUDE.md
49
CLAUDE.md
@@ -29,12 +29,48 @@
|
|||||||
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
||||||
- ✅ ConfigurationGetter interface for handler compatibility
|
- ✅ ConfigurationGetter interface for handler compatibility
|
||||||
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
||||||
|
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
|
||||||
|
- ✅ RefreshPrices for local mode:
|
||||||
|
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
|
||||||
|
- Берёт цены из `local_components.current_price`
|
||||||
|
- Graceful degradation при отсутствии компонента
|
||||||
|
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
|
||||||
|
- Обновлены converters для PriceUpdatedAt
|
||||||
|
- UI кнопка "Пересчитать цену" работает offline/online
|
||||||
|
- ✅ Fixed sync bugs:
|
||||||
|
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
|
||||||
|
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
|
||||||
|
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
|
||||||
|
- Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted`
|
||||||
|
|
||||||
**TODO:**
|
**TODO:**
|
||||||
- ❌ UI: sync status partial (pending badge + sync button + offline indicator)
|
|
||||||
- ❌ RefreshPrices for local mode (via local_components)
|
|
||||||
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||||
|
|
||||||
|
### UI Improvements 🔶 IN PROGRESS
|
||||||
|
|
||||||
|
**1. Sync icon + pricelist badge в header (tasks 4+2):**
|
||||||
|
- ❌ `sync_status.html`: заменить текст Online/Offline на SVG иконку
|
||||||
|
- ❌ Кнопка sync → иконка (circular arrows) вместо текста
|
||||||
|
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации
|
||||||
|
- ❌ `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста
|
||||||
|
- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded
|
||||||
|
|
||||||
|
**2. Прайслисты → вкладка в "Администратор цен" (task 1):**
|
||||||
|
- ❌ `base.html`: убрать отдельную ссылку "Прайслисты" из навигации
|
||||||
|
- ❌ `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты"
|
||||||
|
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку
|
||||||
|
- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить
|
||||||
|
|
||||||
|
**3. Страница настроек: расширить + синхронизация (task 3):**
|
||||||
|
- ❌ `setup.html`: переделать на `{{template "base" .}}` структуру
|
||||||
|
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции
|
||||||
|
- ❌ Секция A: Подключение к БД (текущая форма)
|
||||||
|
- ❌ Секция B: Синхронизация данных:
|
||||||
|
- Статус Online/Offline
|
||||||
|
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты"
|
||||||
|
- Журнал синхронизации (последние N операций)
|
||||||
|
- ❌ Возможно: новый API endpoint для sync log
|
||||||
|
|
||||||
### Phase 3: Projects and Specifications
|
### Phase 3: Projects and Specifications
|
||||||
- qt_projects, qt_specifications tables (MariaDB)
|
- qt_projects, qt_specifications tables (MariaDB)
|
||||||
- Replace qt_configurations → Project/Specification hierarchy
|
- Replace qt_configurations → Project/Specification hierarchy
|
||||||
@@ -65,12 +101,12 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
|
|||||||
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
||||||
|
|
||||||
### SQLite (data/quoteforge.db)
|
### SQLite (data/quoteforge.db)
|
||||||
- `connection_settings` - encrypted DB credentials
|
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
|
||||||
- `local_pricelists/items` - cached from server
|
- `local_pricelists/items` - cached from server
|
||||||
- `local_components` - lot cache for offline search
|
- `local_components` - lot cache for offline search (with current_price)
|
||||||
- `local_configurations` - with sync_status (pending/synced/conflict)
|
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
|
||||||
- `local_projects/specifications` - Phase 3
|
- `local_projects/specifications` - Phase 3
|
||||||
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at)
|
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
|
||||||
|
|
||||||
## Business Logic
|
## Business Logic
|
||||||
|
|
||||||
@@ -91,6 +127,7 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
|
|||||||
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
|
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
|
||||||
| Projects | CRUD /api/projects/:uuid (Phase 3) |
|
| Projects | CRUD /api/projects/:uuid (Phase 3) |
|
||||||
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
|
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
|
||||||
|
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
|
||||||
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
|
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
|
||||||
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
|
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
|
|
||||||
// runSetupMode starts a minimal server that only serves the setup page
|
// runSetupMode starts a minimal server that only serves the setup page
|
||||||
func runSetupMode(local *localdb.LocalDB) {
|
func runSetupMode(local *localdb.LocalDB) {
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
restartSig := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create setup handler", "error", err)
|
slog.Error("failed to create setup handler", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -242,9 +244,21 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
|
||||||
|
|
||||||
slog.Info("setup mode server stopped")
|
select {
|
||||||
|
case <-quit:
|
||||||
|
slog.Info("setup mode server stopped")
|
||||||
|
case <-restartSig:
|
||||||
|
slog.Info("restarting application with saved settings...")
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
// Restart process with same arguments
|
||||||
|
restartProcess()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupLogger(cfg config.LoggingConfig) {
|
func setupLogger(cfg config.LoggingConfig) {
|
||||||
@@ -336,8 +350,8 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup handler (for reconfiguration)
|
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -620,6 +634,26 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
return router, syncService, nil
|
return router, syncService, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restartProcess restarts the current process with the same arguments
|
||||||
|
func restartProcess() {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get executable path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := os.Args
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
slog.Info("executing restart", "executable", executable, "args", args)
|
||||||
|
|
||||||
|
err = syscall.Exec(executable, args, env)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to restart process", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func requestLogger() gin.HandlerFunc {
|
func requestLogger() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SetupHandler struct {
|
type SetupHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
|
restartSig chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) {
|
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -37,8 +38,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHand
|
|||||||
templates["setup.html"] = tmpl
|
templates["setup.html"] = tmpl
|
||||||
|
|
||||||
return &SetupHandler{
|
return &SetupHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
|
restartSig: restartSig,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,13 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
|||||||
port = p
|
port = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If password is empty, try to use saved password
|
||||||
|
if password == "" {
|
||||||
|
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||||
|
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
user, password, host, port, database)
|
user, password, host, port, database)
|
||||||
|
|
||||||
@@ -138,6 +147,13 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
port = p
|
port = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If password is empty, use saved password
|
||||||
|
if password == "" {
|
||||||
|
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||||
|
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
user, password, host, port, database)
|
user, password, host, port, database)
|
||||||
@@ -167,8 +183,16 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Settings saved. Please restart the application.",
|
"message": "Settings saved. Restarting application...",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Signal restart after response is sent
|
||||||
|
if h.restartSig != nil {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||||
|
h.restartSig <- struct{}{}
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the current setup status
|
// GetStatus returns the current setup status
|
||||||
|
|||||||
@@ -285,20 +285,30 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
// SyncStatusPartial renders the sync status partial for htmx
|
// SyncStatusPartial renders the sync status partial for htmx
|
||||||
// GET /partials/sync-status
|
// GET /partials/sync-status
|
||||||
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||||
// Check online status
|
// Check online status from middleware
|
||||||
isOffline, _ := c.Get("is_offline")
|
isOfflineValue, exists := c.Get("is_offline")
|
||||||
|
isOffline := false
|
||||||
|
if exists {
|
||||||
|
isOffline = isOfflineValue.(bool)
|
||||||
|
} else {
|
||||||
|
// Fallback: check directly if middleware didn't set it
|
||||||
|
isOffline = !h.checkOnline()
|
||||||
|
slog.Warn("is_offline not found in context, checking directly")
|
||||||
|
}
|
||||||
|
|
||||||
// Get pending count
|
// Get pending count
|
||||||
pendingCount := h.localDB.GetPendingCount()
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
|
|
||||||
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"IsOffline": isOffline.(bool),
|
"IsOffline": isOffline,
|
||||||
"PendingCount": pendingCount,
|
"PendingCount": pendingCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||||
slog.Error("failed to render sync_status template", "error", err)
|
slog.Error("failed to render sync_status template", "error", err)
|
||||||
c.String(http.StatusInternalServerError, "Template error")
|
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
CreatedAt: cfg.CreatedAt,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
SyncStatus: "pending",
|
SyncStatus: "pending",
|
||||||
@@ -52,16 +53,17 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &models.Configuration{
|
cfg := &models.Configuration{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
UserID: local.OriginalUserID,
|
UserID: local.OriginalUserID,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalPrice: local.TotalPrice,
|
TotalPrice: local.TotalPrice,
|
||||||
CustomPrice: local.CustomPrice,
|
CustomPrice: local.CustomPrice,
|
||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
CreatedAt: local.CreatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if local.ServerID != nil {
|
if local.ServerID != nil {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type LocalConfiguration struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
SyncedAt *time.Time `json:"synced_at"`
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -292,11 +291,71 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
|
|||||||
return userConfigs[start:end], total, nil
|
return userConfigs[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshPrices updates all component prices in the configuration
|
// RefreshPrices updates all component prices in the configuration from local cache
|
||||||
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
||||||
// This requires access to component prices from local cache
|
// Get configuration from local SQLite
|
||||||
// For now, return error as we need to implement component price lookup from local cache
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
return nil, errors.New("refresh prices not yet implemented for local-first mode")
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if localCfg.OriginalUserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices for all items
|
||||||
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
|
for i, item := range localCfg.Items {
|
||||||
|
// Get current component price from local cache
|
||||||
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: *component.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
localCfg.Items = updatedItems
|
||||||
|
total := updatedItems.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if localCfg.ServerCount > 1 {
|
||||||
|
total *= float64(localCfg.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
|
||||||
|
// Set price update timestamp and mark for sync
|
||||||
|
now := time.Now()
|
||||||
|
localCfg.PriceUpdatedAt = &now
|
||||||
|
localCfg.UpdatedAt = now
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
@@ -503,7 +562,62 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
|||||||
|
|
||||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
||||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
// This requires access to component prices from local cache
|
// Get configuration from local SQLite
|
||||||
// For now, return error as we need to implement component price lookup from local cache
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
return nil, errors.New("refresh prices not yet implemented for local-first mode")
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices for all items
|
||||||
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
|
for i, item := range localCfg.Items {
|
||||||
|
// Get current component price from local cache
|
||||||
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: *component.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
localCfg.Items = updatedItems
|
||||||
|
total := updatedItems.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if localCfg.ServerCount > 1 {
|
||||||
|
total *= float64(localCfg.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
|
||||||
|
// Set price update timestamp and mark for sync
|
||||||
|
now := time.Now()
|
||||||
|
localCfg.PriceUpdatedAt = &now
|
||||||
|
localCfg.UpdatedAt = now
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,31 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we have a server ID before updating
|
||||||
|
// If the payload doesn't have ID, get it from local configuration
|
||||||
|
if cfg.ID == 0 {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.ServerID == nil {
|
||||||
|
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||||
|
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||||
|
}
|
||||||
|
cfg.ID = serverCfg.ID
|
||||||
|
|
||||||
|
// Update local with server ID
|
||||||
|
serverID := serverCfg.ID
|
||||||
|
localCfg.ServerID = &serverID
|
||||||
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
|
} else {
|
||||||
|
cfg.ID = *localCfg.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update on server
|
// Update on server
|
||||||
if err := s.configRepo.Update(&cfg); err != nil {
|
if err := s.configRepo.Update(&cfg); err != nil {
|
||||||
return fmt.Errorf("updating configuration on server: %w", err)
|
return fmt.Errorf("updating configuration on server: %w", err)
|
||||||
|
|||||||
@@ -75,21 +75,19 @@ func (w *Worker) runSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.logger.Debug("running background sync")
|
|
||||||
|
|
||||||
// Push pending changes first
|
// Push pending changes first
|
||||||
pushed, err := w.service.PushPendingChanges()
|
pushed, err := w.service.PushPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logger.Warn("failed to push pending changes", "error", err)
|
w.logger.Warn("background sync: failed to push pending changes", "error", err)
|
||||||
} else if pushed > 0 {
|
} else if pushed > 0 {
|
||||||
w.logger.Info("pushed pending changes", "count", pushed)
|
w.logger.Info("background sync: pushed pending changes", "count", pushed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check for new pricelists
|
// Then check for new pricelists
|
||||||
err = w.service.SyncPricelistsIfNeeded()
|
err = w.service.SyncPricelistsIfNeeded()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logger.Warn("failed to sync pricelists", "error", err)
|
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.logger.Debug("background sync completed")
|
w.logger.Info("background sync cycle completed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<!-- Sync Status Indicator (htmx-powered) -->
|
<!-- Sync Status Indicator (htmx-powered) -->
|
||||||
<div id="sync-status"
|
<div id="sync-status"
|
||||||
|
class="flex items-center gap-3 text-sm"
|
||||||
hx-get="/partials/sync-status"
|
hx-get="/partials/sync-status"
|
||||||
hx-trigger="load, refresh from:body, every 30s"
|
hx-trigger="load, refresh from:body, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span id="db-user" class="text-sm text-gray-600"></span>
|
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,12 @@
|
|||||||
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
|
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
|
||||||
|
|
||||||
<div class="flex space-x-3 pt-4">
|
<div class="flex space-x-3 pt-4">
|
||||||
|
{{if .Settings}}
|
||||||
|
<a href="/"
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition text-center">
|
||||||
|
Назад
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
<button type="button" onclick="testConnection()"
|
<button type="button" onclick="testConnection()"
|
||||||
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
|
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
|
||||||
Проверить
|
Проверить
|
||||||
@@ -122,6 +128,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkServerReady() {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30; // 30 seconds max
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/health', { method: 'GET' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Check if we're out of setup mode
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
showStatus('✓ Приложение запущено! Перенаправление...', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server still restarting, continue polling
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
showStatus('Сервер не отвечает. Обновите страницу вручную.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000); // Check every second
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('setup-form').addEventListener('submit', async function(e) {
|
document.getElementById('setup-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showStatus('Сохранение настроек...', 'info');
|
showStatus('Сохранение настроек...', 'info');
|
||||||
@@ -136,9 +170,12 @@
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showStatus(data.message + ' Перенаправление...', 'success');
|
showStatus('✓ ' + data.message, 'success');
|
||||||
|
// Wait for restart and redirect to home
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/';
|
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||||
|
// Poll until server is back
|
||||||
|
checkServerReady();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showStatus(data.error, 'error');
|
showStatus(data.error, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user