diff --git a/LOCAL_FIRST_INTEGRATION.md b/LOCAL_FIRST_INTEGRATION.md deleted file mode 100644 index 28402d2..0000000 --- a/LOCAL_FIRST_INTEGRATION.md +++ /dev/null @@ -1,178 +0,0 @@ -# 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/qfs/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/qfs - -# 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/MIGRATION_PRICE_REFRESH.md b/MIGRATION_PRICE_REFRESH.md deleted file mode 100644 index 0abe33b..0000000 --- a/MIGRATION_PRICE_REFRESH.md +++ /dev/null @@ -1,121 +0,0 @@ -# Миграция: Функционал пересчета цен в конфигураторе - -## Описание изменений - -Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях. - -### Новые возможности - -1. **Кнопка "Пересчитать цену"** на странице конфигуратора - - Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных - - Сохраняет количество компонентов, обновляя только цены - - Отображает время последнего обновления цен - -2. **Поле `price_updated_at`** в таблице конфигураций - - Хранит дату и время последнего обновления цен - - Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.) - -### Изменения в базе данных - -Добавлено новое поле в таблицу `qt_configurations`: -```sql -ALTER TABLE qt_configurations -ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL -AFTER server_count; -``` - -### Новый API endpoint - -``` -POST /api/configs/:uuid/refresh-prices -``` - -**Требования:** -- Авторизация: Bearer Token -- Роль: editor или выше - -**Ответ:** -```json -{ - "id": 1, - "uuid": "...", - "name": "Конфигурация 1", - "items": [ - { - "lot_name": "CPU_AMD_9654", - "quantity": 2, - "unit_price": 11500.00 - } - ], - "total_price": 23000.00, - "price_updated_at": "2026-01-31T12:34:56Z", - ... -} -``` - -## Применение изменений - -### 1. Обновление базы данных - -Запустите сервер с флагом миграции: -```bash -./quoteforge -migrate -config config.yaml -``` - -Или выполните SQL миграцию вручную: -```bash -mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql -``` - -### 2. Перезапуск сервера - -После применения миграции перезапустите сервер: -```bash -./quoteforge -config config.yaml -``` - -## Использование - -1. Откройте любую сохраненную конфигурацию в конфигураторе -2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить" -3. Все цены компонентов будут обновлены до актуальных значений -4. Конфигурация автоматически сохраняется с обновленными ценами -5. Под кнопками отображается время последнего обновления цен - -## Технические детали - -### Измененные файлы - -- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt` -- `internal/services/configuration.go` - добавлен метод `RefreshPrices()` -- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()` -- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices` -- `web/templates/index.html` - добавлена кнопка и JavaScript функции -- `migrations/004_add_price_updated_at.sql` - SQL миграция -- `CLAUDE.md` - обновлена документация - -### Логика обновления цен - -1. Получение конфигурации по UUID -2. Проверка прав доступа (пользователь должен быть владельцем) -3. Для каждого компонента в конфигурации: - - Получение актуальной цены из `qt_lot_metadata.current_price` - - Обновление `unit_price` в items -4. Пересчет `total_price` с учетом `server_count` -5. Установка `price_updated_at` на текущее время -6. Сохранение конфигурации - -### Обработка ошибок - -- Если компонент не найден или у него нет цены - сохраняется старая цена -- При ошибках доступа возвращается 403 Forbidden -- При отсутствии конфигурации возвращается 404 Not Found - -## Отмена изменений (Rollback) - -Для отмены миграции выполните: -```sql -ALTER TABLE qt_configurations DROP COLUMN price_updated_at; -``` - -**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно. diff --git a/README.md b/README.md index f16991d..77d27cb 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ git clone https://github.com/your-company/quoteforge.git cd quoteforge ``` -### 2. Настройка конфигурации +### 2. Настройка runtime-конфига (опционально) -```bash -cp config.example.yaml config.yaml -``` +`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`. +Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат +(оставляя только используемые секции `server` и `logging`). -Отредактируйте `config.yaml`: +При необходимости можно создать/отредактировать файл вручную: ```yaml server: @@ -70,16 +70,10 @@ server: port: 8080 mode: "release" -database: - host: "localhost" - port: 3306 - name: "RFQ_LOG" - user: "quoteforge" - password: "your-secure-password" - -auth: - jwt_secret: "your-jwt-secret-min-32-chars" - token_expiry: "24h" +logging: + level: "info" + format: "json" + output: "stdout" ``` ### 3. Миграции базы данных @@ -96,19 +90,19 @@ go run ./cmd/qfs -migrate Сначала всегда смотрите preview: ```bash -go run ./cmd/migrate_ops_projects -config config.yaml +go run ./cmd/migrate_ops_projects ``` Применение изменений: ```bash -go run ./cmd/migrate_ops_projects -config config.yaml -apply +go run ./cmd/migrate_ops_projects -apply ``` Без интерактивного подтверждения: ```bash -go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes +go run ./cmd/migrate_ops_projects -apply -yes ``` ### Минимальные права БД для пользователя квотаций @@ -222,6 +216,44 @@ make help # Показать все команды - sync API возвращает `423 Locked` с `reason_code` и `reason_text`; - в UI показывается красный индикатор и причина блокировки в модалке синхронизации. +#### Схема потоков данных синхронизации + +```text + [ SERVER / MariaDB ] + ┌───────────────────────────┐ + │ qt_projects │ + │ qt_configurations │ + │ qt_pricelists │ + │ qt_pricelist_items │ + │ qt_pricelist_sync_status │ + └─────────────┬─────────────┘ + │ + pull (projects/configs/pricelists) + │ + ┌──────────────────┴──────────────────┐ + │ │ + [ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ] + ┌───────────────────────────────┐ ┌───────────────────────────────┐ + │ local_projects │ │ local_projects │ + │ local_configurations │ │ local_configurations │ + │ local_pricelists │ │ local_pricelists │ + │ local_pricelist_items │ │ local_pricelist_items │ + │ pending_changes (proj/config) │ │ pending_changes (proj/config) │ + └───────────────┬───────────────┘ └───────────────┬───────────────┘ + │ │ + push (projects/configurations only) push (projects/configurations only) + │ │ + └──────────────────┬────────────────────┘ + │ + [ SERVER / MariaDB ] +``` + +По сущностям: +- Конфигурации: `Client <-> Server <-> Other Clients` +- Проекты: `Client <-> Server <-> Other Clients` +- Прайслисты: `Server -> Clients only` (локальный push отсутствует) +- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями + ### Версионность конфигураций (local-first) Для `local_configurations` используется append-only versioning через полные snapshot-версии: @@ -255,6 +287,7 @@ POST /api/configs/:uuid/rollback ### Локальный config.yaml По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником). +Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`). Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`. ## Docker @@ -285,9 +318,7 @@ quoteforge/ │ ├── templates/ # HTML шаблоны │ └── static/ # CSS, JS, изображения ├── migrations/ # SQL миграции -├── config.yaml # Конфигурация -├── Dockerfile -├── docker-compose.yml +├── config.example.yaml # Пример конфигурации └── go.mod ``` @@ -325,6 +356,17 @@ POST /api/sync/components # Pull components (423, если bloc POST /api/sync/pricelists # Pull pricelists (423, если blocked) ``` +### Краткая карта sync API + +| Endpoint | Назначение | Поток | +|----------|------------|-------| +| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` | +| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` | +| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` | +| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` | +| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` | +| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` | + #### Sync payload для versioning События в `pending_changes` для конфигураций содержат: @@ -336,50 +378,6 @@ POST /api/sync/pricelists # Pull pricelists (423, если bloc Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution. -## Cron Jobs - -QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container. - -### Docker Compose Setup - -The Docker setup includes a dedicated cron service that runs the following jobs: - -- **Alerts check**: Every hour (0 * * * *) -- **Price updates**: Daily at 2 AM (0 2 * * *) -- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0) -- **Popularity score updates**: Daily at 3 AM (0 3 * * *) - -To enable cron jobs in Docker, run: - -```bash -docker-compose up -d -``` - -### Manual Cron Job Execution - -You can also run cron jobs manually using the quoteforge-cron binary: - -```bash -# Check and generate alerts -go run ./cmd/cron -job=alerts - -# Recalculate all prices -go run ./cmd/cron -job=update-prices - -# Reset usage counters -go run ./cmd/cron -job=reset-counters - -# Update popularity scores -go run ./cmd/cron -job=update-popularity -``` - -### Cron Job Details - -- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes -- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average) -- **Usage counter reset**: Resets weekly and monthly usage counters for components -- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity - ## Разработка ```bash diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index c0619a3..ba90c2b 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -7,7 +7,6 @@ import ( "time" "git.mchus.pro/mchus/quoteforge/internal/appstate" - "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "gorm.io/driver/mysql" @@ -16,7 +15,6 @@ import ( ) func main() { - configPath := flag.String("config", "config.yaml", "path to config file") defaultLocalDBPath, err := appstate.ResolveDBPath("") if err != nil { log.Fatalf("Failed to resolve default local SQLite path: %v", err) @@ -28,22 +26,6 @@ func main() { log.Println("QuoteForge Configuration Migration Tool") log.Println("========================================") - // Load config for MariaDB connection - cfg, err := config.Load(*configPath) - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Connect to MariaDB - log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port) - mariaDB, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - log.Fatalf("Failed to connect to MariaDB: %v", err) - } - log.Println("Connected to MariaDB") - // Initialize local SQLite log.Printf("Opening local SQLite at %s...", *localDBPath) local, err := localdb.New(*localDBPath) @@ -51,6 +33,28 @@ func main() { log.Fatalf("Failed to initialize local database: %v", err) } log.Println("Local SQLite initialized") + if !local.HasSettings() { + log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.") + } + + settings, err := local.GetSettings() + if err != nil { + log.Fatalf("Failed to load SQLite connection settings: %v", err) + } + dsn, err := local.GetDSN() + if err != nil { + log.Fatalf("Failed to build DSN from SQLite settings: %v", err) + } + + // Connect to MariaDB + log.Printf("Connecting to MariaDB at %s:%d...", settings.Host, settings.Port) + mariaDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("Failed to connect to MariaDB: %v", err) + } + log.Println("Connected to MariaDB") // Count configurations in MariaDB var serverCount int64 @@ -149,23 +153,7 @@ func main() { log.Printf(" Skipped: %d", skipped) log.Printf(" Errors: %d", errors) - // Save connection settings to local SQLite if not exists - if !local.HasSettings() { - log.Println("\nSaving connection settings to local SQLite...") - if err := local.SaveSettings( - cfg.Database.Host, - cfg.Database.Port, - cfg.Database.Name, - cfg.Database.User, - cfg.Database.Password, - ); err != nil { - log.Printf("Warning: Failed to save settings: %v", err) - } else { - log.Println("Connection settings saved") - } - } - - fmt.Println("\nDone! You can now run the server with: go run ./cmd/server") + fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs") } func derefUint(v *uint) uint { diff --git a/cmd/migrate_ops_projects/main.go b/cmd/migrate_ops_projects/main.go index 3391494..f7d0fb2 100644 --- a/cmd/migrate_ops_projects/main.go +++ b/cmd/migrate_ops_projects/main.go @@ -10,7 +10,8 @@ import ( "sort" "strings" - "git.mchus.pro/mchus/quoteforge/internal/config" + "git.mchus.pro/mchus/quoteforge/internal/appstate" + "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "github.com/google/uuid" "gorm.io/driver/mysql" @@ -38,17 +39,29 @@ type migrationAction struct { } func main() { - configPath := flag.String("config", "config.yaml", "path to config file") + defaultLocalDBPath, err := appstate.ResolveDBPath("") + if err != nil { + log.Fatalf("failed to resolve default local SQLite path: %v", err) + } + localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)") apply := flag.Bool("apply", false, "apply migration (default is preview only)") yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)") flag.Parse() - cfg, err := config.Load(*configPath) + local, err := localdb.New(*localDBPath) if err != nil { - log.Fatalf("failed to load config: %v", err) + log.Fatalf("failed to initialize local database: %v", err) } + if !local.HasSettings() { + log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.") + } + dsn, err := local.GetDSN() + if err != nil { + log.Fatalf("failed to build DSN from SQLite settings: %v", err) + } + dbUser := strings.TrimSpace(local.GetDBUser()) - db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { @@ -59,7 +72,7 @@ func main() { log.Fatalf("precheck failed: %v", err) } - actions, existingProjects, err := buildPlan(db, cfg.Database.User) + actions, existingProjects, err := buildPlan(db, dbUser) if err != nil { log.Fatalf("failed to build migration plan: %v", err) } diff --git a/cmd/qfs/config_migration_test.go b/cmd/qfs/config_migration_test.go new file mode 100644 index 0000000..3bef478 --- /dev/null +++ b/cmd/qfs/config_migration_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.mchus.pro/mchus/quoteforge/internal/config" +) + +func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + legacy := `server: + host: "0.0.0.0" + port: 9191 +database: + host: "legacy-db" + port: 3306 + name: "RFQ_LOG" + user: "old" + password: "old-pass" +pricing: + default_method: "median" +logging: + level: "debug" + format: "text" + output: "stdout" +` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("load legacy config: %v", err) + } + setConfigDefaults(cfg) + if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil { + t.Fatalf("migrate config: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read migrated config: %v", err) + } + text := string(got) + if strings.Contains(text, "database:") { + t.Fatalf("migrated config still contains deprecated database section:\n%s", text) + } + if strings.Contains(text, "pricing:") { + t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text) + } + if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") { + t.Fatalf("migrated config missing required sections:\n%s", text) + } + if !strings.Contains(text, "port: 9191") { + t.Fatalf("migrated config did not preserve server port:\n%s", text) + } + if !strings.Contains(text, "level: debug") { + t.Fatalf("migrated config did not preserve logging level:\n%s", text) + } +} diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 6eaadc0..f79b198 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "errors" "flag" @@ -34,6 +35,7 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -62,18 +64,18 @@ func main() { slog.Info("starting qfs", "version", Version, "executable", exePath) appmeta.SetVersion(Version) - resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) - if err != nil { - slog.Error("failed to resolve config path", "error", err) - os.Exit(1) - } - resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath) if err != nil { slog.Error("failed to resolve local database path", "error", err) os.Exit(1) } + resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath) + if err != nil { + slog.Error("failed to resolve config path", "error", err) + os.Exit(1) + } + // Migrate legacy project-local config path to the user state directory when using defaults. if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" { migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"}) @@ -113,6 +115,10 @@ func main() { } // Load config for server settings (optional) + if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil { + slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err) + os.Exit(1) + } cfg, err := config.Load(resolvedConfigPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -125,6 +131,10 @@ func main() { } } setConfigDefaults(cfg) + if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil { + slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err) + os.Exit(1) + } slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath) setupLogger(cfg.Logging) @@ -316,6 +326,96 @@ func setConfigDefaults(cfg *config.Config) { } } +func ensureDefaultConfigFile(configPath string) error { + if strings.TrimSpace(configPath) == "" { + return fmt.Errorf("config path is empty") + } + if _, err := os.Stat(configPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return err + } + + const defaultConfigYAML = `server: + host: "127.0.0.1" + port: 8080 + mode: "release" + read_timeout: 30s + write_timeout: 30s + +logging: + level: "info" + format: "json" + output: "stdout" +` + if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil { + return err + } + slog.Info("created default config file", "path", configPath) + return nil +} + +type runtimeServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Mode string `yaml:"mode"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` +} + +type runtimeLoggingConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + Output string `yaml:"output"` +} + +type runtimeConfigFile struct { + Server runtimeServerConfig `yaml:"server"` + Logging runtimeLoggingConfig `yaml:"logging"` +} + +// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format. +// Deprecated sections from legacy configs are intentionally dropped. +func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + runtimeCfg := runtimeConfigFile{ + Server: runtimeServerConfig{ + Host: cfg.Server.Host, + Port: cfg.Server.Port, + Mode: cfg.Server.Mode, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + }, + Logging: runtimeLoggingConfig{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + Output: cfg.Logging.Output, + }, + } + + rendered, err := yaml.Marshal(&runtimeCfg) + if err != nil { + return fmt.Errorf("marshal runtime config: %w", err) + } + + current, err := os.ReadFile(configPath) + if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) { + return nil + } + if err := os.WriteFile(configPath, rendered, 0644); err != nil { + return fmt.Errorf("write runtime config: %w", err) + } + slog.Info("migrated config.yaml to runtime format", "path", configPath) + return nil +} + // runSetupMode starts a minimal server that only serves the setup page func runSetupMode(local *localdb.LocalDB) { restartSig := make(chan struct{}, 1) diff --git a/crontab b/crontab deleted file mode 100644 index ac7cf23..0000000 --- a/crontab +++ /dev/null @@ -1,15 +0,0 @@ -# Cron jobs for QuoteForge -# Run alerts check every hour -0 * * * * /app/quoteforge-cron -job=alerts - -# Run price updates daily at 2 AM -0 2 * * * /app/quoteforge-cron -job=update-prices - -# Reset weekly counters every Sunday at 1 AM -0 1 * * 0 /app/quoteforge-cron -job=reset-counters - -# Update popularity scores daily at 3 AM -0 3 * * * /app/quoteforge-cron -job=update-popularity - -# Log rotation (optional) -# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf \ No newline at end of file diff --git a/internal/appstate/path.go b/internal/appstate/path.go index 2468ee9..0d3ff1c 100644 --- a/internal/appstate/path.go +++ b/internal/appstate/path.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" ) const ( @@ -55,6 +56,25 @@ func ResolveConfigPath(explicitPath string) (string, error) { return filepath.Join(dir, defaultCfg), nil } +// ResolveConfigPathNearDB returns config path using priority: +// explicit CLI path > QFS_CONFIG_PATH > directory of resolved local DB path. +// Falls back to ResolveConfigPath when dbPath is empty. +func ResolveConfigPathNearDB(explicitPath, dbPath string) (string, error) { + if explicitPath != "" { + return filepath.Clean(explicitPath), nil + } + + if fromEnv := os.Getenv(envCfgPath); fromEnv != "" { + return filepath.Clean(fromEnv), nil + } + + if strings.TrimSpace(dbPath) != "" { + return filepath.Join(filepath.Dir(filepath.Clean(dbPath)), defaultCfg), nil + } + + return ResolveConfigPath("") +} + // MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars) // to targetPath if targetPath does not already exist. // Returns source path if migration happened. diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 7a13bed..b6d91f6 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -719,6 +719,47 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error { return l.db.Delete(&LocalPricelist{}, id).Error } +// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server +// and not referenced by active local configurations. +func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) { + returned := 0 + err := l.db.Transaction(func(tx *gorm.DB) error { + var candidates []LocalPricelist + query := tx.Model(&LocalPricelist{}) + if len(serverPricelistIDs) > 0 { + query = query.Where("server_id NOT IN ?", serverPricelistIDs) + } + if err := query.Find(&candidates).Error; err != nil { + return err + } + + for i := range candidates { + pl := candidates[i] + var refs int64 + if err := tx.Model(&LocalConfiguration{}). + Where("pricelist_id = ? AND is_active = 1", pl.ServerID). + Count(&refs).Error; err != nil { + return err + } + if refs > 0 { + continue + } + if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil { + return err + } + if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil { + return err + } + returned++ + } + return nil + }) + if err != nil { + return 0, err + } + return returned, nil +} + // PendingChange methods // AddPendingChange adds a change to the sync queue diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 27af05b..7c09a4d 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -340,6 +340,10 @@ func (s *Service) SyncPricelists() (int, error) { if err != nil { return 0, fmt.Errorf("getting active server pricelists: %w", err) } + serverPricelistIDs := make([]uint, 0, len(serverPricelists)) + for i := range serverPricelists { + serverPricelistIDs = append(serverPricelistIDs, serverPricelists[i].ID) + } synced := 0 var latestEstimateLocalID uint @@ -388,6 +392,13 @@ func (s *Service) SyncPricelists() (int, error) { synced++ } + removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs) + if err != nil { + slog.Warn("failed to cleanup stale local pricelists", "error", err) + } else if removed > 0 { + slog.Info("deleted stale local pricelists", "deleted", removed) + } + // Update component prices from latest estimate pricelist only. if latestEstimateLocalID > 0 { updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID) diff --git a/internal/services/sync/service_pricelist_cleanup_test.go b/internal/services/sync/service_pricelist_cleanup_test.go new file mode 100644 index 0000000..33feb02 --- /dev/null +++ b/internal/services/sync/service_pricelist_cleanup_test.go @@ -0,0 +1,85 @@ +package sync_test + +import ( + "testing" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/models" + syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" +) + +func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) { + local := newLocalDBForSyncTest(t) + serverDB := newServerDBForSyncTest(t) + if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil { + t.Fatalf("migrate server pricelist tables: %v", err) + } + + serverPL := models.Pricelist{ + Source: "estimate", + Version: "2026-01-01-001", + Notification: "server", + CreatedBy: "tester", + IsActive: true, + CreatedAt: time.Now().Add(-1 * time.Hour), + } + if err := serverDB.Create(&serverPL).Error; err != nil { + t.Fatalf("create server pricelist: %v", err) + } + if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil { + t.Fatalf("create server pricelist item: %v", err) + } + + if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ + ServerID: 9991, + Source: "estimate", + Version: "old-unused", + Name: "old-unused", + CreatedAt: time.Now().Add(-2 * time.Hour), + SyncedAt: time.Now().Add(-2 * time.Hour), + IsUsed: false, + }); err != nil { + t.Fatalf("seed local missing pricelist: %v", err) + } + missingUsed := &localdb.LocalPricelist{ + ServerID: 9992, + Source: "estimate", + Version: "old-used", + Name: "old-used", + CreatedAt: time.Now().Add(-2 * time.Hour), + SyncedAt: time.Now().Add(-2 * time.Hour), + IsUsed: false, + } + if err := local.SaveLocalPricelist(missingUsed); err != nil { + t.Fatalf("seed local referenced pricelist: %v", err) + } + if err := local.SaveConfiguration(&localdb.LocalConfiguration{ + UUID: "cfg-1", + OriginalUsername: "tester", + Name: "cfg", + Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1}}, + IsActive: true, + PricelistID: &missingUsed.ServerID, + SyncStatus: "synced", + CreatedAt: time.Now().Add(-30 * time.Minute), + UpdatedAt: time.Now().Add(-30 * time.Minute), + }); err != nil { + t.Fatalf("seed local configuration with pricelist ref: %v", err) + } + + svc := syncsvc.NewServiceWithDB(serverDB, local) + if _, err := svc.SyncPricelists(); err != nil { + t.Fatalf("sync pricelists: %v", err) + } + + if _, err := local.GetLocalPricelistByServerID(9991); err == nil { + t.Fatalf("expected unused missing local pricelist to be deleted") + } + if _, err := local.GetLocalPricelistByServerID(9992); err != nil { + t.Fatalf("expected local pricelist referenced by active config to stay: %v", err) + } + if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err != nil { + t.Fatalf("expected server pricelist to be synced locally: %v", err) + } +}