diff --git a/CLAUDE.md b/CLAUDE.md index 5446dcb..990462c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,9 @@ - ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go` **TODO:** -- ❌ Conflict resolution (last-write-wins or manual) -- ❌ UI: pending counter in header -- ❌ UI: manual sync button -- ❌ UI: offline indicator (middleware already exists) +- ❌ 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) ### Phase 3: Projects and Specifications - qt_projects, qt_specifications tables (MariaDB) diff --git a/LOCAL_FIRST_INTEGRATION.md b/LOCAL_FIRST_INTEGRATION.md new file mode 100644 index 0000000..1ec4859 --- /dev/null +++ b/LOCAL_FIRST_INTEGRATION.md @@ -0,0 +1,178 @@ +# Local-First Architecture Integration Guide + +## Overview + +QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации. + +## Реализованные компоненты + +### 1. Конвертеры моделей (`internal/localdb/converters.go`) + +Конвертеры между MariaDB и SQLite моделями: +- `ConfigurationToLocal()` / `LocalToConfiguration()` +- `PricelistToLocal()` / `LocalToPricelist()` +- `ComponentToLocal()` / `LocalToComponent()` + +### 2. LocalDB методы (`internal/localdb/localdb.go`) + +Добавлены методы для работы с pending changes: +- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные +- `GetPendingCount()` - возвращает количество несинхронизированных изменений + +### 3. Sync Service расширения (`internal/services/sync/service.go`) + +Новые методы: +- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости +- `PushPendingChanges()` - отправляет все pending changes на сервер +- `pushSingleChange()` - обрабатывает один pending change +- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций + +**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`: +```go +syncService := sync.NewService(pricelistRepo, configRepo, local) +``` + +### 4. LocalConfigurationService (`internal/services/local_configuration.go`) + +Новый сервис для работы с конфигурациями в local-first режиме: +- Все операции CRUD работают через SQLite +- Автоматически добавляет изменения в pending_changes +- При создании конфигурации (если online) проверяет новые прайслисты + +```go +localConfigService := services.NewLocalConfigurationService( + localDB, + syncService, + quoteService, + isOnlineFunc, +) +``` + +### 5. Sync Handler расширения (`internal/handlers/sync.go`) + +Новые endpoints: +- `POST /api/sync/push` - отправить pending changes на сервер +- `GET /api/sync/pending/count` - получить количество pending changes +- `GET /api/sync/pending` - получить список pending changes + +## Интеграция + +### Шаг 1: Обновить main.go + +```go +// В cmd/server/main.go +syncService := sync.NewService(pricelistRepo, configRepo, local) + +// Создать isOnline функцию +isOnlineFunc := func() bool { + sqlDB, err := db.DB() + if err != nil { + return false + } + return sqlDB.Ping() == nil +} + +// Создать LocalConfigurationService +localConfigService := services.NewLocalConfigurationService( + local, + syncService, + quoteService, + isOnlineFunc, +) +``` + +### Шаг 2: Обновить ConfigurationHandler + +Заменить `ConfigurationService` на `LocalConfigurationService` в handlers: + +```go +// Было: +configHandler := handlers.NewConfigurationHandler(configService, exportService) + +// Стало: +configHandler := handlers.NewConfigurationHandler(localConfigService, exportService) +``` + +### Шаг 3: Добавить endpoints для sync + +В роутере добавить: +```go +syncGroup := router.Group("/api/sync") +{ + syncGroup.POST("/push", syncHandler.PushPendingChanges) + syncGroup.GET("/pending/count", syncHandler.GetPendingCount) + syncGroup.GET("/pending", syncHandler.GetPendingChanges) +} +``` + +## Как это работает + +### Создание конфигурации + +1. Пользователь создает конфигурацию +2. `LocalConfigurationService.Create()`: + - Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты + - Сохраняет конфигурацию в SQLite + - Добавляет в `pending_changes` с operation="create" +3. Конфигурация доступна локально сразу + +### Синхронизация с сервером + +**Manual sync:** +```bash +POST /api/sync/push +``` + +**Background sync (TODO):** +- Периодический worker вызывает `syncService.PushPendingChanges()` +- Проверяет online статус +- Отправляет все pending changes на сервер +- Удаляет успешно синхронизированные записи + +### Offline режим + +1. Все операции работают нормально через SQLite +2. Изменения копятся в `pending_changes` +3. При восстановлении соединения автоматически синхронизируются + +## Pending Changes Queue + +Таблица `pending_changes`: +```go +type PendingChange struct { + ID int64 // Auto-increment + EntityType string // "configuration", "project", "specification" + EntityUUID string // UUID сущности + Operation string // "create", "update", "delete" + Payload string // JSON snapshot сущности + CreatedAt time.Time + Attempts int // Счетчик попыток синхронизации + LastError string // Последняя ошибка синхронизации +} +``` + +## TODO для Phase 2.5 + +- [ ] Background sync worker (автоматическая синхронизация каждые N минут) +- [ ] Conflict resolution (при конфликтах обновления) +- [ ] UI: pending counter в header +- [ ] UI: manual sync button +- [ ] UI: conflict alerts +- [ ] Retry logic для failed pending changes +- [ ] RefreshPrices для local mode (через local_components) + +## Testing + +```bash +# Compile +go build ./cmd/server + +# Run +./quoteforge + +# Check pending changes +curl http://localhost:8080/api/sync/pending/count + +# Manual sync +curl -X POST http://localhost:8080/api/sync/push +``` diff --git a/internal/repository/unified.go b/internal/repository/unified.go new file mode 100644 index 0000000..51636b4 --- /dev/null +++ b/internal/repository/unified.go @@ -0,0 +1,399 @@ +package repository + +import ( + "encoding/json" + "fmt" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + "gorm.io/gorm" +) + +// DataSource defines the unified interface for data access +// It abstracts whether data comes from MariaDB (online) or SQLite (offline) +type DataSource interface { + // Components + GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) + GetComponent(lotName string) (*models.LotMetadata, error) + + // Configurations + SaveConfiguration(cfg *models.Configuration) error + GetConfigurations(userID uint) ([]models.Configuration, error) + GetConfigurationByUUID(uuid string) (*models.Configuration, error) + DeleteConfiguration(uuid string) error + + // Pricelists (read-only in offline mode) + GetPricelists() ([]models.PricelistSummary, error) + GetPricelistByID(id uint) (*models.Pricelist, error) + GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) + GetLatestPricelist() (*models.Pricelist, error) +} + +// UnifiedRepo implements DataSource with automatic online/offline switching +type UnifiedRepo struct { + mariaDB *gorm.DB + localDB *localdb.LocalDB + isOnline bool +} + +// NewUnifiedRepo creates a new unified repository +func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo { + return &UnifiedRepo{ + mariaDB: mariaDB, + localDB: localDB, + isOnline: isOnline, + } +} + +// SetOnlineStatus updates the online/offline status +func (r *UnifiedRepo) SetOnlineStatus(online bool) { + r.isOnline = online +} + +// IsOnline returns the current online/offline status +func (r *UnifiedRepo) IsOnline() bool { + return r.isOnline +} + +// Component methods + +// GetComponents returns components from MariaDB (online) or local cache (offline) +func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + if r.isOnline { + return r.getComponentsOnline(filter, offset, limit) + } + return r.getComponentsOffline(filter, offset, limit) +} + +func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + repo := NewComponentRepository(r.mariaDB) + return repo.List(filter, offset, limit) +} + +func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { + var components []localdb.LocalComponent + query := r.localDB.DB().Model(&localdb.LocalComponent{}) + + // Apply filters + if filter.Category != "" { + query = query.Where("category = ?", filter.Category) + } + if filter.Search != "" { + search := "%" + filter.Search + "%" + query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search) + } + if filter.HasPrice { + query = query.Where("current_price IS NOT NULL AND current_price > 0") + } + + var total int64 + query.Count(&total) + + // Apply sorting + sortDir := "ASC" + if filter.SortDir == "desc" { + sortDir = "DESC" + } + switch filter.SortField { + case "current_price": + query = query.Order("current_price " + sortDir) + case "lot_name": + query = query.Order("lot_name " + sortDir) + default: + query = query.Order("lot_name ASC") + } + + if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil { + return nil, 0, fmt.Errorf("fetching offline components: %w", err) + } + + // Convert to models.LotMetadata + result := make([]models.LotMetadata, len(components)) + for i, comp := range components { + result[i] = models.LotMetadata{ + LotName: comp.LotName, + Model: comp.Model, + CurrentPrice: comp.CurrentPrice, + Lot: &models.Lot{ + LotName: comp.LotName, + LotDescription: comp.LotDescription, + }, + } + } + + return result, total, nil +} + +// GetComponent returns a single component by lot name +func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) { + if r.isOnline { + repo := NewComponentRepository(r.mariaDB) + return repo.GetByLotName(lotName) + } + + var comp localdb.LocalComponent + if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil { + return nil, fmt.Errorf("fetching offline component: %w", err) + } + + return &models.LotMetadata{ + LotName: comp.LotName, + Model: comp.Model, + CurrentPrice: comp.CurrentPrice, + Lot: &models.Lot{ + LotName: comp.LotName, + LotDescription: comp.LotDescription, + }, + }, nil +} + +// Configuration methods + +// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes) +func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + return repo.Create(cfg) + } + + // Offline: save to local SQLite and queue for sync + localCfg := &localdb.LocalConfiguration{ + UUID: cfg.UUID, + Name: cfg.Name, + TotalPrice: cfg.TotalPrice, + CustomPrice: cfg.CustomPrice, + Notes: cfg.Notes, + IsTemplate: cfg.IsTemplate, + ServerCount: cfg.ServerCount, + CreatedAt: cfg.CreatedAt, + UpdatedAt: time.Now(), + SyncStatus: "pending", + } + + // Convert items + localItems := make(localdb.LocalConfigItems, len(cfg.Items)) + for i, item := range cfg.Items { + localItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + localCfg.Items = localItems + + if err := r.localDB.SaveConfiguration(localCfg); err != nil { + return fmt.Errorf("saving local configuration: %w", err) + } + + // Add to pending changes queue + payload, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling configuration for sync: %w", err) + } + + return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)) +} + +// GetConfigurations returns all configurations for a user +func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + configs, _, err := repo.ListByUser(userID, 0, 1000) + return configs, err + } + + // Offline: get from local SQLite + localConfigs, err := r.localDB.GetConfigurations() + if err != nil { + return nil, fmt.Errorf("fetching local configurations: %w", err) + } + + // Convert to models.Configuration + result := make([]models.Configuration, len(localConfigs)) + for i, lc := range localConfigs { + items := make(models.ConfigItems, len(lc.Items)) + for j, item := range lc.Items { + items[j] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + result[i] = models.Configuration{ + UUID: lc.UUID, + Name: lc.Name, + Items: items, + TotalPrice: lc.TotalPrice, + CustomPrice: lc.CustomPrice, + Notes: lc.Notes, + IsTemplate: lc.IsTemplate, + ServerCount: lc.ServerCount, + CreatedAt: lc.CreatedAt, + } + } + + return result, nil +} + +// GetConfigurationByUUID returns a configuration by UUID +func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) { + if r.isOnline { + repo := NewConfigurationRepository(r.mariaDB) + return repo.GetByUUID(uuid) + } + + localCfg, err := r.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, fmt.Errorf("fetching local configuration: %w", err) + } + + items := make(models.ConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + items[i] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + } + } + + return &models.Configuration{ + UUID: localCfg.UUID, + Name: localCfg.Name, + Items: items, + TotalPrice: localCfg.TotalPrice, + CustomPrice: localCfg.CustomPrice, + Notes: localCfg.Notes, + IsTemplate: localCfg.IsTemplate, + ServerCount: localCfg.ServerCount, + CreatedAt: localCfg.CreatedAt, + }, nil +} + +// DeleteConfiguration deletes a configuration +func (r *UnifiedRepo) DeleteConfiguration(uuid string) error { + if r.isOnline { + // Get ID first + cfg, err := r.GetConfigurationByUUID(uuid) + if err != nil { + return err + } + repo := NewConfigurationRepository(r.mariaDB) + return repo.Delete(cfg.ID) + } + + // Offline: delete from local and queue sync + if err := r.localDB.DeleteConfiguration(uuid); err != nil { + return fmt.Errorf("deleting local configuration: %w", err) + } + + return r.localDB.AddPendingChange("configuration", uuid, "delete", "") +} + +// Pricelist methods + +// GetPricelists returns all pricelists +func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + summaries, _, err := repo.List(0, 1000) + return summaries, err + } + + // Offline: get from local cache + localPLs, err := r.localDB.GetLocalPricelists() + if err != nil { + return nil, fmt.Errorf("fetching local pricelists: %w", err) + } + + summaries := make([]models.PricelistSummary, len(localPLs)) + for i, pl := range localPLs { + itemCount := r.localDB.CountLocalPricelistItems(pl.ID) + summaries[i] = models.PricelistSummary{ + ID: pl.ServerID, + Version: pl.Version, + CreatedAt: pl.CreatedAt, + ItemCount: itemCount, + } + } + + return summaries, nil +} + +// GetPricelistByID returns a pricelist by ID +func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + return repo.GetByID(id) + } + + // Offline: get from local cache + localPL, err := r.localDB.GetLocalPricelistByServerID(id) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist: %w", err) + } + + itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) + return &models.Pricelist{ + ID: localPL.ServerID, + Version: localPL.Version, + CreatedAt: localPL.CreatedAt, + ItemCount: int(itemCount), + }, nil +} + +// GetPricelistItems returns items for a pricelist +func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + items, _, err := repo.GetItems(pricelistID, 0, 100000, "") + return items, err + } + + // Offline: get from local cache + // First find the local pricelist by server ID + localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist: %w", err) + } + + localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID) + if err != nil { + return nil, fmt.Errorf("fetching local pricelist items: %w", err) + } + + items := make([]models.PricelistItem, len(localItems)) + for i, item := range localItems { + items[i] = models.PricelistItem{ + ID: item.ID, + PricelistID: pricelistID, + LotName: item.LotName, + Price: item.Price, + } + } + + return items, nil +} + +// GetLatestPricelist returns the latest pricelist +func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) { + if r.isOnline { + repo := NewPricelistRepository(r.mariaDB) + return repo.GetLatestActive() + } + + // Offline: get from local cache + localPL, err := r.localDB.GetLatestLocalPricelist() + if err != nil { + return nil, fmt.Errorf("fetching latest local pricelist: %w", err) + } + + itemCount := r.localDB.CountLocalPricelistItems(localPL.ID) + return &models.Pricelist{ + ID: localPL.ServerID, + Version: localPL.Version, + CreatedAt: localPL.CreatedAt, + ItemCount: int(itemCount), + }, nil +}